From 025c1a180ff4fb96f18ea1bf92e04eb60318c563 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Sun, 1 Mar 2026 17:04:26 +0000 Subject: [PATCH] Add Zod validation to all API endpoints and remove debug test route Adds schema-based input validation across helcim, events, members, series, admin, and updates API endpoints. Removes the peer-support debug test endpoint. Adds validation test coverage. --- .gitignore | 2 +- docs/SECURITY_EVALUATION.md | 101 +++- server/api/admin/events/[id].put.js | 12 +- server/api/admin/members.post.js | 10 +- server/api/admin/series.post.js | 13 +- server/api/admin/series.put.js | 12 +- server/api/admin/series/[id].put.js | 5 +- server/api/admin/series/tickets.put.js | 16 +- server/api/auth/verify.get.js | 10 + .../events/[id]/cancel-registration.post.js | 9 +- .../events/[id]/check-registration.post.js | 9 +- server/api/events/[id]/guest-register.post.js | 12 +- server/api/events/[id]/payment.post.js | 12 +- .../[id]/tickets/check-eligibility.post.js | 9 +- .../api/events/[id]/tickets/purchase.post.js | 10 +- .../api/events/[id]/tickets/reserve.post.js | 9 +- server/api/events/[id]/waitlist.delete.js | 9 +- server/api/events/[id]/waitlist.post.js | 9 +- server/api/helcim/create-plan.post.js | 18 +- server/api/helcim/customer-code.get.js | 7 +- server/api/helcim/customer.post.js | 19 +- .../api/helcim/get-or-create-customer.post.js | 7 +- server/api/helcim/initialize-payment.post.js | 9 +- server/api/helcim/plans.get.js | 6 +- server/api/helcim/subscription.post.js | 71 ++- server/api/helcim/subscriptions.get.js | 6 +- server/api/helcim/update-billing.post.js | 25 +- server/api/members/create.post.js | 18 +- server/api/members/me/peer-support.patch.js | 2 +- .../api/members/update-contribution.post.js | 29 +- .../[id]/tickets/check-eligibility.post.js | 11 +- .../api/series/[id]/tickets/purchase.post.js | 10 +- server/api/test/peer-support-debug.get.js | 28 - server/api/updates/[id].patch.js | 2 +- server/utils/schemas.js | 232 ++++++++ tests/server/api/helcim-auth.test.js | 52 ++ .../api/members-create-response.test.js | 61 ++ tests/server/api/validation-phase3.test.js | 559 ++++++++++++++++++ 38 files changed, 1132 insertions(+), 309 deletions(-) delete mode 100644 server/api/test/peer-support-debug.get.js create mode 100644 tests/server/api/helcim-auth.test.js create mode 100644 tests/server/api/members-create-response.test.js create mode 100644 tests/server/api/validation-phase3.test.js diff --git a/.gitignore b/.gitignore index a3170a0..6653be5 100644 --- a/.gitignore +++ b/.gitignore @@ -17,7 +17,7 @@ logs .DS_Store .fleet .idea -docs/* +/docs/ # Local env files .env diff --git a/docs/SECURITY_EVALUATION.md b/docs/SECURITY_EVALUATION.md index b337246..ed84abc 100644 --- a/docs/SECURITY_EVALUATION.md +++ b/docs/SECURITY_EVALUATION.md @@ -1,6 +1,6 @@ # Ghost Guild Security Evaluation -**Date:** 2026-02-28 (updated 2026-03-01) +**Date:** 2026-02-28 (Phases 0-3 complete as of 2026-03-01) **Framework:** OWASP ASVS v4.0, Level 1 **Scope:** Full application stack (Nuxt 4 + Nitro server + MongoDB) @@ -132,14 +132,12 @@ ASVS Level 1 targets "all software" and is achievable for a small team. |---|---|---| | 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) +## Remediation Summary (Phases 0-2) All work lives on branch `security/asvs-remediation`. @@ -175,6 +173,22 @@ All work lives on branch `security/asvs-remediation`. ### Session management - 7-day token expiry with refresh endpoint at `/api/auth/refresh`. +### Input validation (`server/utils/schemas.js` + `server/utils/validateBody.js`) +- Centralized Zod schemas for all API endpoints. `validateBody(event, schema)` replaces `readBody` and returns only validated fields (stripping unknown properties). +- `memberCreateSchema`: Explicit allowlist (email, name, circle, contributionTier). Prevents mass assignment of `role`, `status`, `helcimCustomerId`, `_id`. +- `emailSchema`: Trims, lowercases, and validates email format. +- `memberProfileUpdateSchema`: Type/length validation on all profile fields and privacy enum values. +- `updateCreateSchema`: Content length limit (1–50000 chars), image URL array (max 20). +- `adminEventCreateSchema`: Required title, description, dates; optional capacity/location/pricing/tickets. +- `paymentVerifySchema`: Validates cardToken and customerId presence. + +### Additional hardening +- Logout cookie flags now match login flags (`httpOnly: true`, `secure` conditional on NODE_ENV). +- Removed 3 unauthenticated test/debug endpoints (`test-connection`, `test-subscription`, `test-bot`). +- Removed sensitive `console.log` statements from Helcim and member creation endpoints. +- Removed unused `bcryptjs` dependency. +- Added 10MB file size limit on image uploads. + --- ## Remediation Roadmap @@ -203,25 +217,48 @@ All work lives on branch `security/asvs-remediation`. | 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 +### Phase 2: Hardening (within 30 days of launch) -- COMPLETE | # | 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 | +| 15 | H7 | Implement Zod validation across all API endpoints | V5.1.3 | Done | +| 16 | M5 | Escape regex in directory search | V5.3.4 | Done | +| 17 | M4 | Remove sensitive console.log statements | V7.1.1 | Done | +| 18 | M3 | Make devtools conditional on NODE_ENV | V7.4.1 | Done | | 19 | M1 | Shorter session tokens (7d) with refresh endpoint | V2.5.2 | Done | | 20 | -- | Create shared `requireAuth()`/`requireAdmin()` utilities | V4.1.1 | Done | +| 21 | -- | Fix mass assignment in member creation (`new Member(body)`) | V5.1.3 | Done | +| 22 | -- | Fix logout cookie flags to match login (httpOnly, secure) | V3.2.1 | Done | +| 23 | -- | Remove unauthenticated test/debug endpoints | V4.1.1 | Done | +| 24 | -- | Remove dead `bcryptjs` dependency | -- | Done | +| 25 | -- | Add 10MB file size limit on image uploads | V13.1.1 | Done | -### Phase 3: Before building planned features +### Phase 3: Remaining hardening -- COMPLETE + +| # | Severity | Fix | ASVS | Status | +|---|----------|-----|------|--------| +| 26 | HIGH | `create-plan.post.js` has no auth guard -- anyone can create Helcim payment plans | V4.1.1 | Done | +| 27 | HIGH | `plans.get.js` and `subscriptions.get.js` have no auth -- expose Helcim plan/subscription data | V4.1.1 | Done | +| 28 | MEDIUM | `create.post.js` returns full Mongoose member document in response (leaks `role`, `status`, `helcimCustomerId`, internal fields) | V5.1.2 | Done | +| 29 | MEDIUM | Helcim error text forwarded to client in `statusMessage` across 7 endpoints (`create-plan`, `customer-code`, `initialize-payment`, `subscription`, `update-billing`, `customer`, `get-or-create-customer`) | V7.4.1 | Done | +| 30 | MEDIUM | `tickets/purchase.post.js` and 24 other endpoints still use raw `readBody` without Zod validation | V5.1.3 | Done | +| 31 | LOW | `server/api/test/peer-support-debug.get.js` is a debug endpoint in a `test/` directory -- has auth but should be removed before production | V14.2.2 | Done | + +**Phase 3 remediation details:** +- Items 26-27: Added `await requireAdmin(event)` to `create-plan.post.js`, `plans.get.js`, and `subscriptions.get.js`. +- Item 28: Member creation response now returns an explicit projection (`id`, `email`, `name`, `circle`, `contributionTier`, `status`). Also fixed `subscription.post.js` (5 return paths) and `update-contribution.post.js` (4 return paths) to not expose full member documents. Fixed `get-or-create-customer.post.js` error text forwarding. +- Item 29: Replaced all `` `Failed to X: ${errorText}` `` patterns with generic client messages. Also fixed outer catch blocks that forwarded `error.message` -- these now re-throw known `createError` instances and use generic messages for unknown errors. +- Item 30: Added 25 new Zod schemas to `server/utils/schemas.js` and migrated all 25 endpoints from `readBody` to `validateBody`. Total schema count: 32 (7 from Phase 2 + 25 from Phase 3). All POST/PUT/PATCH/DELETE endpoints with request bodies now use Zod validation. +- Item 31: Deleted `server/api/test/peer-support-debug.get.js` and the `server/api/test/` directory. + +### Phase 4: 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 | +| 32 | Build sanitization utility (DOMPurify wrapper) for all user-generated HTML | Open | +| 33 | Design admin role model with granular permissions | Open | +| 34 | Implement file validation pipeline (type, size, virus scanning) | Open | +| 35 | Design credential management patterns (encrypted at rest) | Open | --- @@ -244,7 +281,7 @@ npm run test:run # Single run (CI) - `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) +### Test Coverage (213 tests across 12 files) | File | Tests | Security Controls Verified | |------|-------|---------------------------| @@ -254,8 +291,12 @@ npm run test:run # Single run (CI) | `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/auth-login.test.js` | 4 | Existing and non-existing emails return identical response shape and message, missing email = 400 via Zod (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) | +| `tests/server/api/validation.test.js` | 38 | Mass assignment: `role`, `status`, `helcimCustomerId`, `_id` stripped from member create; email format validation on login/register/create; content length limits; image array limits; payment token validation; enum validation for circles, tiers, privacy; `validateBody` integration (H7, V5.1.3) | +| `tests/server/api/helcim-auth.test.js` | 6 | `create-plan.post.js`, `plans.get.js`, `subscriptions.get.js` all call `requireAdmin` before business logic (Items 26-27, V4.1.1) | +| `tests/server/api/members-create-response.test.js` | 9 | Response projection: no raw `member` return, explicit `_id`/`email`/`name`/`circle`/`status` fields, no `helcimCustomerId` or `role` in response, no `error.message` forwarding (Item 28, V5.1.2) | +| `tests/server/api/validation-phase3.test.js` | 81 | 25 new Zod schemas: invalid input rejected, valid input passes, unknown fields stripped, mass assignment prevented; error text forwarding regression checks on 8 Helcim endpoints; validateBody migration verified across all 25 migrated endpoints (Items 29-30, V5.1.3, V7.4.1) | ### Manual Test Cases @@ -271,12 +312,38 @@ These items require browser or network-level verification and are not covered by **Item 8 (Payment auth):** Each endpoint with no cookie = 401. Full join flow still completes. +**Item 21 (Mass assignment):** `curl -X POST /api/members/create -d '{"email":"test@test.com","name":"Test","circle":"community","contributionTier":"0","role":"admin"}' -H 'Content-Type: application/json'` -- response member must have `role: "member"`, not `"admin"`. + +**Item 22 (Logout cookie):** Log in, verify `auth-token` cookie exists. Log out, verify `auth-token` cookie is cleared. `document.cookie` must not contain `auth-token` at any point. + +**Item 23 (Deleted endpoints):** `curl /api/helcim/test-connection`, `/api/helcim/test-subscription`, `/api/slack/test-bot` must all return 404. + +**Item 25 (Upload size):** Upload a file >10MB to `/api/upload/image` -- must return 400 "File too large". + ### Integration verification (after each phase) -- `npm run test:run` -- all 79 tests pass +- `npm run test:run` -- all 213 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 + +--- + +## Notes for Future Rounds + +### Zod v4 behavior to be aware of + +- **Transform ordering matters.** `.trim().toLowerCase()` must come BEFORE `.email()`. Zod v4 runs transforms in chain order, so `.email().trim()` validates the untrimmed string first and rejects `" user@example.com "`. This is opposite to some other validation libraries. +- **`.optional()` does NOT accept `null`.** Only `undefined`. If the frontend sends `null` to clear a field, Zod rejects it. Use `.nullable()` or `.nullish()` if null-clearing is needed. Audit frontend form behavior before migrating remaining endpoints. +- **Unknown keys are stripped, not rejected.** `z.object()` silently drops fields not in the schema. This is the desired behavior for mass assignment prevention, but means typo'd field names from the frontend will be silently ignored rather than producing an error. Use `.strict()` on schemas if you want to reject unexpected fields. + +### Patterns that recurred during Phases 2-3 + +- **Auth guard gaps.** (Fixed in Phase 3, items 26-27.) Every new endpoint needs `requireAuth` or `requireAdmin`. Recommendation: add a grep check to CI that flags any `server/api/` handler not containing `requireAuth`, `requireAdmin`, or an explicit `// @public` comment. +- **Error text forwarding.** (Fixed in Phase 3, item 29.) The pattern `` statusMessage: `Failed to X: ${errorText}` `` leaks upstream API error details to the client. Standard pattern is now: generic client message + `console.error` for server-side logging. +- **Response over-exposure.** (Fixed in Phase 3, item 28.) `return { success: true, member }` sends the full Mongoose document. Standard pattern is now: explicit projection to allowlisted fields. +- **`readBody` audit.** (Fixed in Phase 3, item 30.) All POST/PUT/PATCH/DELETE endpoints now use `validateBody` with Zod schemas. 32 total schemas across 32 endpoints. +- **Debug endpoints.** (Fixed in Phase 3, item 31.) `server/api/test/` directory deleted. Consider a build-time check or `.gitignore` pattern to prevent future `server/api/test/` additions. diff --git a/server/api/admin/events/[id].put.js b/server/api/admin/events/[id].put.js index 542b727..3928344 100644 --- a/server/api/admin/events/[id].put.js +++ b/server/api/admin/events/[id].put.js @@ -7,15 +7,7 @@ export default defineEventHandler(async (event) => { await requireAdmin(event) const eventId = getRouterParam(event, 'id') - const body = await readBody(event) - - // Validate required fields - if (!body.title || !body.description || !body.startDate || !body.endDate) { - throw createError({ - statusCode: 400, - statusMessage: 'Missing required fields' - }) - } + const body = await validateBody(event, adminEventUpdateSchema) await connectDB() @@ -63,7 +55,7 @@ export default defineEventHandler(async (event) => { console.error('Error updating event:', error) throw createError({ statusCode: 500, - statusMessage: error.message || 'Failed to update event' + statusMessage: 'An unexpected error occurred' }) } }) diff --git a/server/api/admin/members.post.js b/server/api/admin/members.post.js index 677a6cf..cfb6fcb 100644 --- a/server/api/admin/members.post.js +++ b/server/api/admin/members.post.js @@ -6,15 +6,7 @@ export default defineEventHandler(async (event) => { try { await requireAdmin(event) - const body = await readBody(event) - - // Validate required fields - if (!body.name || !body.email || !body.circle || !body.contributionTier) { - throw createError({ - statusCode: 400, - statusMessage: 'Missing required fields' - }) - } + const body = await validateBody(event, adminMemberCreateSchema) await connectDB() diff --git a/server/api/admin/series.post.js b/server/api/admin/series.post.js index d61af01..4b4cb2f 100644 --- a/server/api/admin/series.post.js +++ b/server/api/admin/series.post.js @@ -7,15 +7,7 @@ export default defineEventHandler(async (event) => { const admin = await requireAdmin(event) await connectDB() - const body = await readBody(event) - - // Validate required fields - if (!body.id || !body.title || !body.description) { - throw createError({ - statusCode: 400, - statusMessage: 'Series ID, title, and description are required' - }) - } + const body = await validateBody(event, adminSeriesCreateSchema) // Create new series const newSeries = new Series({ @@ -43,9 +35,10 @@ export default defineEventHandler(async (event) => { }) } + if (error.statusCode) throw error throw createError({ statusCode: 500, - statusMessage: error.message || 'Failed to create series' + statusMessage: 'An unexpected error occurred' }) } }) \ No newline at end of file diff --git a/server/api/admin/series.put.js b/server/api/admin/series.put.js index 88a9ba3..e667321 100644 --- a/server/api/admin/series.put.js +++ b/server/api/admin/series.put.js @@ -8,16 +8,9 @@ export default defineEventHandler(async (event) => { await requireAdmin(event) await connectDB() - const body = await readBody(event) + const body = await validateBody(event, adminSeriesUpdateSchema) const { id, title, description, type, totalEvents } = body - if (!id || !title) { - throw createError({ - statusCode: 400, - statusMessage: 'Series ID and title are required' - }) - } - // Update the series record const updatedSeries = await Series.findOneAndUpdate( { id }, @@ -55,10 +48,11 @@ export default defineEventHandler(async (event) => { return updatedSeries } catch (error) { + if (error.statusCode) throw error console.error('Error updating series:', error) throw createError({ statusCode: 500, - statusMessage: 'Failed to update series' + statusMessage: 'An unexpected error occurred' }) } }) diff --git a/server/api/admin/series/[id].put.js b/server/api/admin/series/[id].put.js index a303754..2ca9f25 100644 --- a/server/api/admin/series/[id].put.js +++ b/server/api/admin/series/[id].put.js @@ -9,7 +9,7 @@ export default defineEventHandler(async (event) => { await connectDB() const id = getRouterParam(event, 'id') - const body = await readBody(event) + const body = await validateBody(event, adminSeriesItemUpdateSchema) if (!id) { throw createError({ @@ -55,10 +55,11 @@ export default defineEventHandler(async (event) => { data: series } } catch (error) { + if (error.statusCode) throw error console.error('Error updating series:', error) throw createError({ statusCode: 500, - statusMessage: error.message || 'Failed to update series' + statusMessage: 'An unexpected error occurred' }) } }) \ No newline at end of file diff --git a/server/api/admin/series/tickets.put.js b/server/api/admin/series/tickets.put.js index 50ed385..de3643b 100644 --- a/server/api/admin/series/tickets.put.js +++ b/server/api/admin/series/tickets.put.js @@ -8,23 +8,9 @@ export default defineEventHandler(async (event) => { await requireAdmin(event) await connectDB() - const body = await readBody(event) + const body = await validateBody(event, adminSeriesTicketsSchema) const { id, tickets } = body - if (!id) { - throw createError({ - statusCode: 400, - statusMessage: 'Series ID is required' - }) - } - - if (!tickets) { - throw createError({ - statusCode: 400, - statusMessage: 'Tickets configuration is required' - }) - } - // Find the series const series = await Series.findOne({ id }) diff --git a/server/api/auth/verify.get.js b/server/api/auth/verify.get.js index d5f4c1f..16655e2 100644 --- a/server/api/auth/verify.get.js +++ b/server/api/auth/verify.get.js @@ -29,6 +29,13 @@ export default defineEventHandler(async (event) => { statusMessage: 'Member not found' }) } + + if (member.status === 'suspended' || member.status === 'cancelled') { + throw createError({ + statusCode: 403, + statusMessage: 'Account is ' + member.status + }) + } // Create a new session token for the authenticated user const sessionToken = jwt.sign( @@ -49,6 +56,9 @@ export default defineEventHandler(async (event) => { await sendRedirect(event, '/members', 302) } catch (err) { + if (err.statusCode && err.statusCode !== 401) { + throw err + } throw createError({ statusCode: 401, statusMessage: 'Invalid or expired token' diff --git a/server/api/events/[id]/cancel-registration.post.js b/server/api/events/[id]/cancel-registration.post.js index 5f94ed5..a0dee6e 100644 --- a/server/api/events/[id]/cancel-registration.post.js +++ b/server/api/events/[id]/cancel-registration.post.js @@ -6,16 +6,9 @@ import { export default defineEventHandler(async (event) => { const id = getRouterParam(event, "id"); - const body = await readBody(event); + const body = await validateBody(event, cancelRegistrationSchema); const { email } = body; - if (!email) { - throw createError({ - statusCode: 400, - statusMessage: "Email is required", - }); - } - try { // Check if id is a valid ObjectId or treat as slug const isObjectId = /^[0-9a-fA-F]{24}$/.test(id); diff --git a/server/api/events/[id]/check-registration.post.js b/server/api/events/[id]/check-registration.post.js index 95b354c..b5feaa7 100644 --- a/server/api/events/[id]/check-registration.post.js +++ b/server/api/events/[id]/check-registration.post.js @@ -2,16 +2,9 @@ import Event from "../../../models/event"; export default defineEventHandler(async (event) => { const id = getRouterParam(event, "id"); - const body = await readBody(event); + const body = await validateBody(event, checkRegistrationSchema); const { email } = body; - if (!email) { - throw createError({ - statusCode: 400, - statusMessage: "Email is required", - }); - } - try { // Check if id is a valid ObjectId or treat as slug const isObjectId = /^[0-9a-fA-F]{24}$/.test(id); diff --git a/server/api/events/[id]/guest-register.post.js b/server/api/events/[id]/guest-register.post.js index 3d31737..9ee4fc2 100644 --- a/server/api/events/[id]/guest-register.post.js +++ b/server/api/events/[id]/guest-register.post.js @@ -6,8 +6,8 @@ export default defineEventHandler(async (event) => { try { await connectDB() const identifier = getRouterParam(event, 'id') - const body = await readBody(event) - + const body = await validateBody(event, guestRegisterSchema) + if (!identifier) { throw createError({ statusCode: 400, @@ -15,14 +15,6 @@ export default defineEventHandler(async (event) => { }) } - // Validate required fields for guest registration - if (!body.name || !body.email) { - throw createError({ - statusCode: 400, - statusMessage: 'Name and email are required' - }) - } - // Fetch the event let eventData if (mongoose.Types.ObjectId.isValid(identifier)) { diff --git a/server/api/events/[id]/payment.post.js b/server/api/events/[id]/payment.post.js index 6d3c634..2f885a6 100644 --- a/server/api/events/[id]/payment.post.js +++ b/server/api/events/[id]/payment.post.js @@ -8,8 +8,8 @@ export default defineEventHandler(async (event) => { try { await connectDB() const identifier = getRouterParam(event, 'id') - const body = await readBody(event) - + const body = await validateBody(event, eventPaymentSchema) + if (!identifier) { throw createError({ statusCode: 400, @@ -17,14 +17,6 @@ export default defineEventHandler(async (event) => { }) } - // Validate required payment fields - if (!body.name || !body.email || !body.paymentToken) { - throw createError({ - statusCode: 400, - statusMessage: 'Name, email, and payment token are required' - }) - } - // Fetch the event let eventData if (mongoose.Types.ObjectId.isValid(identifier)) { diff --git a/server/api/events/[id]/tickets/check-eligibility.post.js b/server/api/events/[id]/tickets/check-eligibility.post.js index d407658..c0d5820 100644 --- a/server/api/events/[id]/tickets/check-eligibility.post.js +++ b/server/api/events/[id]/tickets/check-eligibility.post.js @@ -9,14 +9,7 @@ import { connectDB } from "../../../../utils/mongoose.js"; export default defineEventHandler(async (event) => { try { await connectDB(); - const body = await readBody(event); - - if (!body.email) { - throw createError({ - statusCode: 400, - statusMessage: "Email is required", - }); - } + const body = await validateBody(event, ticketEligibilitySchema); // Check if user is a member const member = await Member.findOne({ diff --git a/server/api/events/[id]/tickets/purchase.post.js b/server/api/events/[id]/tickets/purchase.post.js index 43588fc..fa3ca1a 100644 --- a/server/api/events/[id]/tickets/purchase.post.js +++ b/server/api/events/[id]/tickets/purchase.post.js @@ -18,7 +18,7 @@ export default defineEventHandler(async (event) => { try { await connectDB(); const identifier = getRouterParam(event, "id"); - const body = await readBody(event); + const body = await validateBody(event, ticketPurchaseSchema); if (!identifier) { throw createError({ @@ -27,14 +27,6 @@ export default defineEventHandler(async (event) => { }); } - // Validate required fields - if (!body.name || !body.email) { - throw createError({ - statusCode: 400, - statusMessage: "Name and email are required", - }); - } - // Fetch the event let eventData; if (mongoose.Types.ObjectId.isValid(identifier)) { diff --git a/server/api/events/[id]/tickets/reserve.post.js b/server/api/events/[id]/tickets/reserve.post.js index aa681cc..92d7fb6 100644 --- a/server/api/events/[id]/tickets/reserve.post.js +++ b/server/api/events/[id]/tickets/reserve.post.js @@ -16,7 +16,7 @@ export default defineEventHandler(async (event) => { try { await connectDB(); const identifier = getRouterParam(event, "id"); - const body = await readBody(event); + const body = await validateBody(event, ticketReserveSchema); if (!identifier) { throw createError({ @@ -25,13 +25,6 @@ export default defineEventHandler(async (event) => { }); } - if (!body.email) { - throw createError({ - statusCode: 400, - statusMessage: "Email is required", - }); - } - // Fetch the event let eventData; if (mongoose.Types.ObjectId.isValid(identifier)) { diff --git a/server/api/events/[id]/waitlist.delete.js b/server/api/events/[id]/waitlist.delete.js index 3ab9fbe..9e7c9d2 100644 --- a/server/api/events/[id]/waitlist.delete.js +++ b/server/api/events/[id]/waitlist.delete.js @@ -2,17 +2,10 @@ import Event from "../../../models/event"; export default defineEventHandler(async (event) => { const id = getRouterParam(event, "id"); - const body = await readBody(event); + const body = await validateBody(event, waitlistDeleteSchema); const { email } = body; - if (!email) { - throw createError({ - statusCode: 400, - statusMessage: "Email is required", - }); - } - try { // Find event by ID or slug const eventData = await Event.findOne({ diff --git a/server/api/events/[id]/waitlist.post.js b/server/api/events/[id]/waitlist.post.js index 3bb29d7..6930e81 100644 --- a/server/api/events/[id]/waitlist.post.js +++ b/server/api/events/[id]/waitlist.post.js @@ -3,17 +3,10 @@ import Member from "../../../models/member"; export default defineEventHandler(async (event) => { const id = getRouterParam(event, "id"); - const body = await readBody(event); + const body = await validateBody(event, waitlistSchema); const { name, email, membershipLevel } = body; - if (!email) { - throw createError({ - statusCode: 400, - statusMessage: "Email is required", - }); - } - try { // Find event by ID or slug const eventData = await Event.findOne({ diff --git a/server/api/helcim/create-plan.post.js b/server/api/helcim/create-plan.post.js index 9b9c1a4..96b0314 100644 --- a/server/api/helcim/create-plan.post.js +++ b/server/api/helcim/create-plan.post.js @@ -3,16 +3,9 @@ const HELCIM_API_BASE = 'https://api.helcim.com/v2' export default defineEventHandler(async (event) => { try { + await requireAdmin(event) const config = useRuntimeConfig(event) - const body = await readBody(event) - - // Validate required fields - if (!body.name || !body.amount || !body.frequency) { - throw createError({ - statusCode: 400, - statusMessage: 'Name, amount, and frequency are required' - }) - } + const body = await validateBody(event, helcimCreatePlanSchema) const helcimToken = config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN @@ -38,7 +31,7 @@ export default defineEventHandler(async (event) => { throw createError({ statusCode: response.status, - statusMessage: `Failed to create payment plan: ${errorText}` + statusMessage: 'Payment plan creation failed' }) } @@ -50,10 +43,11 @@ export default defineEventHandler(async (event) => { } } catch (error) { + if (error.statusCode) throw error console.error('Error creating Helcim payment plan:', error) throw createError({ - statusCode: error.statusCode || 500, - statusMessage: error.message || 'Failed to create payment plan' + statusCode: 500, + statusMessage: 'An unexpected error occurred' }) } }) diff --git a/server/api/helcim/customer-code.get.js b/server/api/helcim/customer-code.get.js index 90e21af..9f9d406 100644 --- a/server/api/helcim/customer-code.get.js +++ b/server/api/helcim/customer-code.get.js @@ -61,7 +61,7 @@ export default defineEventHandler(async (event) => { const errorText = await response.text() throw createError({ statusCode: response.status, - statusMessage: `Failed to get customer: ${errorText}` + statusMessage: 'Customer lookup failed' }) } @@ -74,10 +74,11 @@ export default defineEventHandler(async (event) => { } } catch (error) { + if (error.statusCode) throw error console.error('Error getting customer code:', error) throw createError({ - statusCode: error.statusCode || 500, - statusMessage: error.message || 'Failed to get customer code' + statusCode: 500, + statusMessage: 'An unexpected error occurred' }) } }) diff --git a/server/api/helcim/customer.post.js b/server/api/helcim/customer.post.js index 42d266d..9393d06 100644 --- a/server/api/helcim/customer.post.js +++ b/server/api/helcim/customer.post.js @@ -9,15 +9,7 @@ export default defineEventHandler(async (event) => { try { await connectDB() const config = useRuntimeConfig(event) - const body = await readBody(event) - - // Validate required fields - if (!body.name || !body.email) { - throw createError({ - statusCode: 400, - statusMessage: 'Name and email are required' - }) - } + const body = await validateBody(event, helcimCustomerSchema) // Check if member already exists const existingMember = await Member.findOne({ email: body.email }) @@ -58,7 +50,7 @@ export default defineEventHandler(async (event) => { console.error('Connection test failed:', testError) throw createError({ statusCode: 401, - statusMessage: `Helcim API connection failed: ${testError.message}` + statusMessage: 'Payment service unavailable' }) } @@ -82,7 +74,7 @@ export default defineEventHandler(async (event) => { console.error('Customer creation failed:', customerResponse.status, errorText) throw createError({ statusCode: customerResponse.status, - statusMessage: `Failed to create customer: ${errorText}` + statusMessage: 'Customer creation failed' }) } @@ -133,10 +125,11 @@ export default defineEventHandler(async (event) => { } } } catch (error) { + if (error.statusCode) throw error console.error('Error creating Helcim customer:', error) throw createError({ - statusCode: error.statusCode || 500, - statusMessage: error.message || 'Failed to create customer' + statusCode: 500, + statusMessage: 'An unexpected error occurred' }) } }) \ No newline at end of file diff --git a/server/api/helcim/get-or-create-customer.post.js b/server/api/helcim/get-or-create-customer.post.js index f294a32..52a6abc 100644 --- a/server/api/helcim/get-or-create-customer.post.js +++ b/server/api/helcim/get-or-create-customer.post.js @@ -100,7 +100,7 @@ export default defineEventHandler(async (event) => { console.error('Failed to create Helcim customer:', createResponse.status, errorText) throw createError({ statusCode: createResponse.status, - statusMessage: `Failed to create Helcim customer: ${errorText}` + statusMessage: 'Customer creation failed' }) } @@ -118,10 +118,11 @@ export default defineEventHandler(async (event) => { } } catch (error) { + if (error.statusCode) throw error console.error('Error in get-or-create-customer:', error) throw createError({ - statusCode: error.statusCode || 500, - statusMessage: error.message || 'Failed to get or create customer' + statusCode: 500, + statusMessage: 'An unexpected error occurred' }) } }) diff --git a/server/api/helcim/initialize-payment.post.js b/server/api/helcim/initialize-payment.post.js index 3888bf9..7064cba 100644 --- a/server/api/helcim/initialize-payment.post.js +++ b/server/api/helcim/initialize-payment.post.js @@ -7,7 +7,7 @@ export default defineEventHandler(async (event) => { try { await requireAuth(event); const config = useRuntimeConfig(event); - const body = await readBody(event); + const body = await validateBody(event, helcimInitializePaymentSchema); const helcimToken = @@ -64,7 +64,7 @@ export default defineEventHandler(async (event) => { ); throw createError({ statusCode: response.status, - statusMessage: `Failed to initialize payment: ${errorText}`, + statusMessage: 'Payment initialization failed', }); } @@ -76,10 +76,11 @@ export default defineEventHandler(async (event) => { secretToken: paymentData.secretToken, }; } catch (error) { + if (error.statusCode) throw error; console.error("Error initializing HelcimPay:", error); throw createError({ - statusCode: error.statusCode || 500, - statusMessage: error.message || "Failed to initialize payment", + statusCode: 500, + statusMessage: "An unexpected error occurred", }); } }); diff --git a/server/api/helcim/plans.get.js b/server/api/helcim/plans.get.js index 6b71c92..12278db 100644 --- a/server/api/helcim/plans.get.js +++ b/server/api/helcim/plans.get.js @@ -3,6 +3,7 @@ const HELCIM_API_BASE = 'https://api.helcim.com/v2' export default defineEventHandler(async (event) => { try { + await requireAdmin(event) const config = useRuntimeConfig(event) const helcimToken = config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN @@ -30,10 +31,11 @@ export default defineEventHandler(async (event) => { } } catch (error) { + if (error.statusCode) throw error console.error('Error fetching Helcim payment plans:', error) throw createError({ - statusCode: error.statusCode || 500, - statusMessage: error.message || 'Failed to fetch payment plans' + statusCode: 500, + statusMessage: 'An unexpected error occurred' }) } }) \ No newline at end of file diff --git a/server/api/helcim/subscription.post.js b/server/api/helcim/subscription.post.js index dafacc3..f5ebd3e 100644 --- a/server/api/helcim/subscription.post.js +++ b/server/api/helcim/subscription.post.js @@ -76,22 +76,7 @@ export default defineEventHandler(async (event) => { await requireAuth(event) await connectDB() const config = useRuntimeConfig(event) - const body = await readBody(event) - - // Validate required fields - if (!body.customerId || !body.contributionTier) { - throw createError({ - statusCode: 400, - statusMessage: 'Customer ID and contribution tier are required' - }) - } - - if (!body.customerCode) { - throw createError({ - statusCode: 400, - statusMessage: 'Customer code is required for subscription creation' - }) - } + const body = await validateBody(event, helcimSubscriptionSchema) // Check if payment is required if (!requiresPayment(body.contributionTier)) { @@ -112,7 +97,14 @@ export default defineEventHandler(async (event) => { return { success: true, subscription: null, - member + member: { + id: member._id, + email: member.email, + name: member.name, + circle: member.circle, + contributionTier: member.contributionTier, + status: member.status + } } } @@ -152,7 +144,14 @@ export default defineEventHandler(async (event) => { status: 'needs_plan_setup', nextBillingDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) }, - member, + member: { + id: member._id, + email: member.email, + name: member.name, + circle: member.circle, + contributionTier: member.contributionTier, + status: member.status + }, warning: `Payment successful but recurring plan needs to be set up in Helcim for the ${body.contributionTier} tier` } } @@ -222,17 +221,23 @@ export default defineEventHandler(async (event) => { subscription: { subscriptionId: 'manual-' + Date.now(), status: 'needs_setup', - error: errorText, nextBillingDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) }, - member, + member: { + id: member._id, + email: member.email, + name: member.name, + circle: member.circle, + contributionTier: member.contributionTier, + status: member.status + }, warning: 'Payment successful but recurring subscription needs manual setup' } } throw createError({ statusCode: subscriptionResponse.status, - statusMessage: `Failed to create subscription: ${errorText}` + statusMessage: 'Subscription creation failed' }) } @@ -267,7 +272,14 @@ export default defineEventHandler(async (event) => { status: subscription.status, nextBillingDate: subscription.nextBillingDate }, - member + member: { + id: member._id, + email: member.email, + name: member.name, + circle: member.circle, + contributionTier: member.contributionTier, + status: member.status + } } } catch (fetchError) { console.error('Error during subscription creation:', fetchError) @@ -294,18 +306,25 @@ export default defineEventHandler(async (event) => { subscription: { subscriptionId: 'manual-' + Date.now(), status: 'needs_setup', - error: fetchError.message, nextBillingDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) }, - member, + member: { + id: member._id, + email: member.email, + name: member.name, + circle: member.circle, + contributionTier: member.contributionTier, + status: member.status + }, warning: 'Payment successful but recurring subscription needs manual setup' } } } catch (error) { + if (error.statusCode) throw error console.error('Error creating Helcim subscription:', error) throw createError({ - statusCode: error.statusCode || 500, - statusMessage: error.message || 'Failed to create subscription' + statusCode: 500, + statusMessage: 'An unexpected error occurred' }) } }) \ No newline at end of file diff --git a/server/api/helcim/subscriptions.get.js b/server/api/helcim/subscriptions.get.js index 111e33d..6a29d44 100644 --- a/server/api/helcim/subscriptions.get.js +++ b/server/api/helcim/subscriptions.get.js @@ -3,6 +3,7 @@ const HELCIM_API_BASE = 'https://api.helcim.com/v2' export default defineEventHandler(async (event) => { try { + await requireAdmin(event) const config = useRuntimeConfig(event) const helcimToken = config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN @@ -30,10 +31,11 @@ export default defineEventHandler(async (event) => { } } catch (error) { + if (error.statusCode) throw error console.error('Error fetching Helcim subscriptions:', error) throw createError({ - statusCode: error.statusCode || 500, - statusMessage: error.message || 'Failed to fetch subscriptions' + statusCode: 500, + statusMessage: 'An unexpected error occurred' }) } }) \ No newline at end of file diff --git a/server/api/helcim/update-billing.post.js b/server/api/helcim/update-billing.post.js index e1199b6..08054b3 100644 --- a/server/api/helcim/update-billing.post.js +++ b/server/api/helcim/update-billing.post.js @@ -7,25 +7,9 @@ export default defineEventHandler(async (event) => { try { await requireAuth(event) const config = useRuntimeConfig(event) - const body = await readBody(event) - - // Validate required fields - if (!body.customerId || !body.billingAddress) { - throw createError({ - statusCode: 400, - statusMessage: 'Customer ID and billing address are required' - }) - } + const body = await validateBody(event, helcimUpdateBillingSchema) const { billingAddress } = body - - // Validate billing address fields - if (!billingAddress.street || !billingAddress.city || !billingAddress.country || !billingAddress.postalCode) { - throw createError({ - statusCode: 400, - statusMessage: 'Complete billing address is required' - }) - } const helcimToken = config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN @@ -54,7 +38,7 @@ export default defineEventHandler(async (event) => { console.error('Billing address update failed:', response.status, errorText) throw createError({ statusCode: response.status, - statusMessage: `Failed to update billing address: ${errorText}` + statusMessage: 'Billing update failed' }) } @@ -65,10 +49,11 @@ export default defineEventHandler(async (event) => { customer: customerData } } catch (error) { + if (error.statusCode) throw error console.error('Error updating billing address:', error) throw createError({ - statusCode: error.statusCode || 500, - statusMessage: error.message || 'Failed to update billing address' + statusCode: 500, + statusMessage: 'An unexpected error occurred' }) } }) \ No newline at end of file diff --git a/server/api/members/create.post.js b/server/api/members/create.post.js index b9ae293..d413741 100644 --- a/server/api/members/create.post.js +++ b/server/api/members/create.post.js @@ -100,14 +100,24 @@ export default defineEventHandler(async (event) => { // TODO: Send welcome email - return { success: true, member } + return { + success: true, + member: { + id: member._id, + email: member.email, + name: member.name, + circle: member.circle, + contributionTier: member.contributionTier, + status: member.status + } + } } catch (error) { if (error.statusCode) { throw error } - throw createError({ - statusCode: 400, - statusMessage: error.message + throw createError({ + statusCode: 400, + statusMessage: 'Member creation failed' }) } }) \ No newline at end of file diff --git a/server/api/members/me/peer-support.patch.js b/server/api/members/me/peer-support.patch.js index b130346..6afb963 100644 --- a/server/api/members/me/peer-support.patch.js +++ b/server/api/members/me/peer-support.patch.js @@ -25,7 +25,7 @@ export default defineEventHandler(async (event) => { }); } - const body = await readBody(event); + const body = await validateBody(event, peerSupportUpdateSchema); // Build update object for peer support settings const updateData = { diff --git a/server/api/members/update-contribution.post.js b/server/api/members/update-contribution.post.js index 6749880..adc7f03 100644 --- a/server/api/members/update-contribution.post.js +++ b/server/api/members/update-contribution.post.js @@ -14,7 +14,7 @@ export default defineEventHandler(async (event) => { try { await connectDB(); const config = useRuntimeConfig(event); - const body = await readBody(event); + const body = await validateBody(event, updateContributionSchema); const token = getCookie(event, "auth-token"); if (!token) { @@ -35,17 +35,6 @@ export default defineEventHandler(async (event) => { }); } - // Validate contribution tier - if ( - !body.contributionTier || - !isValidContributionValue(body.contributionTier) - ) { - throw createError({ - statusCode: 400, - statusMessage: "Invalid contribution tier", - }); - } - // Get member const member = await Member.findById(decoded.memberId); if (!member) { @@ -63,7 +52,6 @@ export default defineEventHandler(async (event) => { return { success: true, message: "Already on this tier", - member, }; } @@ -186,7 +174,7 @@ export default defineEventHandler(async (event) => { if (!subscriptionResponse.ok) { const errorText = await subscriptionResponse.text(); console.error("Failed to create subscription:", errorText); - throw new Error(`Failed to create subscription: ${errorText}`); + throw new Error('Subscription creation failed'); } const subscriptionData = await subscriptionResponse.json(); @@ -206,7 +194,6 @@ export default defineEventHandler(async (event) => { return { success: true, message: "Successfully upgraded to paid tier", - member, subscription: { subscriptionId: subscription.id, status: subscription.status, @@ -262,7 +249,6 @@ export default defineEventHandler(async (event) => { return { success: true, message: "Successfully downgraded to free tier", - member, }; } @@ -311,7 +297,7 @@ export default defineEventHandler(async (event) => { response.status, errorText, ); - throw new Error(`Failed to update subscription: ${errorText}`); + throw new Error('Subscription update failed'); } const subscriptionData = await response.json(); @@ -323,14 +309,13 @@ export default defineEventHandler(async (event) => { return { success: true, message: "Successfully updated contribution level", - member, subscription: subscriptionData, }; } catch (error) { console.error("Error updating Helcim subscription:", error); throw createError({ statusCode: 500, - statusMessage: error.message || "Failed to update subscription", + statusMessage: "Subscription update failed", }); } } @@ -342,13 +327,13 @@ export default defineEventHandler(async (event) => { return { success: true, message: "Successfully updated contribution level", - member, }; } catch (error) { + if (error.statusCode) throw error; console.error("Error updating contribution tier:", error); throw createError({ - statusCode: error.statusCode || 500, - statusMessage: error.message || "Failed to update contribution tier", + statusCode: 500, + statusMessage: "An unexpected error occurred", }); } }); diff --git a/server/api/series/[id]/tickets/check-eligibility.post.js b/server/api/series/[id]/tickets/check-eligibility.post.js index 708a4ef..fbe9035 100644 --- a/server/api/series/[id]/tickets/check-eligibility.post.js +++ b/server/api/series/[id]/tickets/check-eligibility.post.js @@ -2,17 +2,10 @@ import Member from "../../../../models/member.js"; export default defineEventHandler(async (event) => { try { - const body = await readBody(event); + const body = await validateBody(event, seriesTicketEligibilitySchema); const { email } = body; - if (!email) { - return { - isMember: false, - message: "Email is required", - }; - } - - const member = await Member.findOne({ email: email.toLowerCase() }); + const member = await Member.findOne({ email }); if (!member) { return { diff --git a/server/api/series/[id]/tickets/purchase.post.js b/server/api/series/[id]/tickets/purchase.post.js index 1482143..e309b5d 100644 --- a/server/api/series/[id]/tickets/purchase.post.js +++ b/server/api/series/[id]/tickets/purchase.post.js @@ -14,17 +14,9 @@ import { sendSeriesPassConfirmation } from "../../../../utils/resend.js"; export default defineEventHandler(async (event) => { try { const seriesId = getRouterParam(event, "id"); - const body = await readBody(event); + const body = await validateBody(event, seriesTicketPurchaseSchema); const { name, email, paymentId } = body; - // Validate input - if (!name || !email) { - throw createError({ - statusCode: 400, - statusMessage: "Name and email are required", - }); - } - // Fetch series // Build query conditions based on whether seriesId looks like ObjectId or string const isObjectId = /^[0-9a-fA-F]{24}$/.test(seriesId); diff --git a/server/api/test/peer-support-debug.get.js b/server/api/test/peer-support-debug.get.js deleted file mode 100644 index 4073cb2..0000000 --- a/server/api/test/peer-support-debug.get.js +++ /dev/null @@ -1,28 +0,0 @@ -import jwt from "jsonwebtoken"; -import Member from "../../models/member.js"; -import { connectDB } from "../../utils/mongoose.js"; - -export default defineEventHandler(async (event) => { - await connectDB(); - - const token = getCookie(event, "auth-token"); - if (!token) { - throw createError({ statusCode: 401, statusMessage: "Not authenticated" }); - } - - let memberId; - try { - const decoded = jwt.verify(token, useRuntimeConfig().jwtSecret); - memberId = decoded.memberId; - } catch (err) { - throw createError({ statusCode: 401, statusMessage: "Invalid token" }); - } - - const member = await Member.findById(memberId).select("name peerSupport slackUserId"); - - return { - name: member.name, - peerSupport: member.peerSupport, - slackUserId: member.slackUserId, - }; -}); diff --git a/server/api/updates/[id].patch.js b/server/api/updates/[id].patch.js index a1cf726..d8871f8 100644 --- a/server/api/updates/[id].patch.js +++ b/server/api/updates/[id].patch.js @@ -26,7 +26,7 @@ export default defineEventHandler(async (event) => { } const id = getRouterParam(event, "id"); - const body = await readBody(event); + const body = await validateBody(event, updatePatchSchema); try { const update = await Update.findById(id); diff --git a/server/utils/schemas.js b/server/utils/schemas.js index 5278098..c319bfb 100644 --- a/server/utils/schemas.js +++ b/server/utils/schemas.js @@ -64,6 +64,135 @@ export const paymentVerifySchema = z.object({ customerId: z.string().min(1) }) +// --- Helcim schemas --- + +export const helcimCreatePlanSchema = z.object({ + name: z.string().min(1).max(200), + amount: z.union([z.string().min(1), z.number().positive()]), + frequency: z.string().min(1).max(50), + currency: z.string().max(10).optional() +}) + +export const helcimCustomerSchema = z.object({ + name: z.string().min(1).max(200), + email: z.string().trim().toLowerCase().email(), + circle: z.enum(['community', 'founder', 'practitioner']).optional(), + contributionTier: z.enum(['0', '5', '15', '30', '50']).optional() +}) + +export const helcimInitializePaymentSchema = z.object({ + amount: z.number().min(0).optional(), + customerCode: z.string().max(200).optional(), + metadata: z.object({ + type: z.string().max(100).optional(), + eventTitle: z.string().max(500).optional(), + eventId: z.string().max(200).optional() + }).optional() +}) + +export const helcimSubscriptionSchema = z.object({ + customerId: z.union([z.string().min(1), z.number()]), + contributionTier: z.enum(['0', '5', '15', '30', '50']), + customerCode: z.string().min(1).max(200), + cardToken: z.string().max(500).optional() +}) + +export const helcimUpdateBillingSchema = z.object({ + customerId: z.union([z.string().min(1), z.number()]), + billingAddress: z.object({ + name: z.string().max(200).optional(), + street: z.string().min(1).max(500), + city: z.string().min(1).max(200), + province: z.string().max(200).optional(), + state: z.string().max(200).optional(), + country: z.string().min(1).max(100), + postalCode: z.string().min(1).max(20) + }) +}) + +// --- Event ticket/registration schemas --- + +export const ticketPurchaseSchema = z.object({ + name: z.string().min(1).max(200), + email: z.string().trim().toLowerCase().email(), + transactionId: z.string().max(500).optional() +}) + +export const ticketReserveSchema = z.object({ + email: z.string().trim().toLowerCase().email() +}) + +export const ticketEligibilitySchema = z.object({ + email: z.string().trim().toLowerCase().email() +}) + +export const waitlistSchema = z.object({ + name: z.string().max(200).optional(), + email: z.string().trim().toLowerCase().email(), + membershipLevel: z.string().max(100).optional() +}) + +export const waitlistDeleteSchema = z.object({ + email: z.string().trim().toLowerCase().email() +}) + +export const cancelRegistrationSchema = z.object({ + email: z.string().trim().toLowerCase().email() +}) + +export const checkRegistrationSchema = z.object({ + email: z.string().trim().toLowerCase().email() +}) + +export const guestRegisterSchema = z.object({ + name: z.string().min(1).max(200), + email: z.string().trim().toLowerCase().email() +}) + +export const eventPaymentSchema = z.object({ + name: z.string().min(1).max(200), + email: z.string().trim().toLowerCase().email(), + paymentToken: z.string().min(1).max(500) +}) + +// --- Member schemas --- + +export const updateContributionSchema = z.object({ + contributionTier: z.enum(['0', '5', '15', '30', '50']) +}) + +export const peerSupportUpdateSchema = z.object({ + enabled: z.boolean().optional(), + skillTopics: z.array(z.string().max(200)).max(20).optional(), + supportTopics: z.array(z.string().max(200)).max(20).optional(), + availability: z.string().max(500).optional(), + personalMessage: z.string().max(2000).optional(), + slackUsername: z.string().max(200).optional() +}) + +// --- Update schemas --- + +export const updatePatchSchema = z.object({ + content: z.string().min(1).max(50000).optional(), + images: z.array(z.string().url()).max(20).optional(), + privacy: z.enum(['public', 'members', 'private']).optional(), + commentsEnabled: z.boolean().optional() +}) + +// --- Series ticket schemas --- + +export const seriesTicketPurchaseSchema = z.object({ + name: z.string().min(1).max(200), + email: z.string().trim().toLowerCase().email(), + paymentId: z.string().max(500).optional() +}) + +export const seriesTicketEligibilitySchema = z.object({ + email: z.string().trim().toLowerCase().email() +}) + +// --- Admin schemas --- + export const adminEventCreateSchema = z.object({ title: z.string().min(1).max(500), description: z.string().min(1).max(50000), @@ -94,3 +223,106 @@ export const adminEventCreateSchema = z.object({ tags: z.array(z.string().max(100)).max(20).optional(), series: z.string().optional() }) + +export const adminEventUpdateSchema = z.object({ + title: z.string().min(1).max(500), + description: z.string().min(1).max(50000), + startDate: z.string().min(1), + endDate: z.string().min(1), + location: z.string().max(500).optional(), + maxAttendees: z.number().int().positive().optional().nullable(), + membersOnly: z.boolean().optional(), + registrationDeadline: z.string().optional().nullable(), + pricing: z.object({ + paymentRequired: z.boolean().optional(), + isFree: z.boolean().optional(), + publicPrice: z.number().min(0).optional() + }).optional(), + tickets: z.object({ + enabled: z.boolean().optional(), + public: z.object({ + available: z.boolean().optional(), + name: z.string().max(200).optional(), + description: z.string().max(2000).optional(), + price: z.number().min(0).optional(), + quantity: z.number().int().positive().optional().nullable(), + sold: z.number().int().min(0).optional(), + earlyBirdPrice: z.number().min(0).optional().nullable(), + earlyBirdDeadline: z.string().optional().nullable() + }).optional() + }).optional(), + image: z.string().url().optional().nullable(), + category: z.string().max(100).optional(), + tags: z.array(z.string().max(100)).max(20).optional(), + series: z.any().optional(), + slug: z.string().max(500).optional() +}).passthrough() + +export const adminSeriesCreateSchema = z.object({ + id: z.string().min(1).max(200), + title: z.string().min(1).max(500), + description: z.string().min(1).max(50000), + type: z.string().max(100).optional(), + totalEvents: z.number().int().positive().optional().nullable() +}) + +export const adminSeriesUpdateSchema = z.object({ + id: z.string().min(1).max(200), + title: z.string().min(1).max(500), + description: z.string().max(50000).optional(), + type: z.string().max(100).optional(), + totalEvents: z.number().int().positive().optional().nullable() +}) + +export const adminSeriesItemUpdateSchema = z.object({ + title: z.string().min(1).max(500).optional(), + description: z.string().max(50000).optional(), + type: z.string().max(100).optional(), + totalEvents: z.number().int().positive().optional().nullable(), + isActive: z.boolean().optional() +}) + +export const adminSeriesTicketsSchema = z.object({ + id: z.string().min(1).max(200), + tickets: z.object({ + enabled: z.boolean().optional(), + requiresSeriesTicket: z.boolean().optional(), + allowIndividualEventTickets: z.boolean().optional(), + currency: z.string().max(10).optional(), + member: z.object({ + available: z.boolean().optional(), + isFree: z.boolean().optional(), + price: z.number().min(0).optional(), + name: z.string().max(200).optional(), + description: z.string().max(2000).optional(), + circleOverrides: z.record(z.any()).optional() + }).optional(), + public: z.object({ + available: z.boolean().optional(), + name: z.string().max(200).optional(), + description: z.string().max(2000).optional(), + price: z.number().min(0).optional(), + quantity: z.number().int().positive().optional().nullable(), + sold: z.number().int().min(0).optional(), + reserved: z.number().int().min(0).optional(), + earlyBirdPrice: z.number().min(0).optional().nullable(), + earlyBirdDeadline: z.string().optional().nullable() + }).optional(), + capacity: z.object({ + total: z.number().int().positive().optional().nullable(), + reserved: z.number().int().min(0).optional() + }).optional(), + waitlist: z.object({ + enabled: z.boolean().optional(), + maxSize: z.number().int().positive().optional().nullable(), + entries: z.array(z.any()).optional() + }).optional() + }) +}) + +export const adminMemberCreateSchema = z.object({ + name: z.string().min(1).max(200), + email: z.string().trim().toLowerCase().email(), + circle: z.enum(['community', 'founder', 'practitioner']), + contributionTier: z.enum(['0', '5', '15', '30', '50']) +}) diff --git a/tests/server/api/helcim-auth.test.js b/tests/server/api/helcim-auth.test.js new file mode 100644 index 0000000..c07c80c --- /dev/null +++ b/tests/server/api/helcim-auth.test.js @@ -0,0 +1,52 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// Test that the three Helcim admin endpoints require admin auth. +// We verify the handler files import/call requireAdmin by checking +// the module source, and we test that requireAdmin rejects properly +// via the existing auth.test.js infrastructure. + +// We test the schema + handler wiring by reading the file and +// confirming requireAdmin is the first call in the handler. + +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' + +const serverDir = resolve(import.meta.dirname, '../../../server/api/helcim') + +describe('Helcim admin endpoint auth guards', () => { + const files = [ + 'create-plan.post.js', + 'plans.get.js', + 'subscriptions.get.js' + ] + + for (const file of files) { + describe(file, () => { + const source = readFileSync(resolve(serverDir, file), 'utf-8') + + it('calls requireAdmin', () => { + expect(source).toContain('requireAdmin(event)') + }) + + it('calls requireAdmin before any business logic', () => { + const adminIndex = source.indexOf('requireAdmin(event)') + const readBodyIndex = source.indexOf('readBody(event)') + const validateBodyIndex = source.indexOf('validateBody(event') + const fetchIndex = source.indexOf('fetch(') + + expect(adminIndex).toBeGreaterThan(-1) + + // requireAdmin must come before readBody/validateBody/fetch + if (readBodyIndex > -1) { + expect(adminIndex).toBeLessThan(readBodyIndex) + } + if (validateBodyIndex > -1) { + expect(adminIndex).toBeLessThan(validateBodyIndex) + } + if (fetchIndex > -1) { + expect(adminIndex).toBeLessThan(fetchIndex) + } + }) + }) + } +}) diff --git a/tests/server/api/members-create-response.test.js b/tests/server/api/members-create-response.test.js new file mode 100644 index 0000000..932ad6c --- /dev/null +++ b/tests/server/api/members-create-response.test.js @@ -0,0 +1,61 @@ +import { describe, it, expect } from 'vitest' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' + +const createPostPath = resolve( + import.meta.dirname, + '../../../server/api/members/create.post.js' +) + +describe('members/create.post.js response projection', () => { + const source = readFileSync(createPostPath, 'utf-8') + + it('does NOT return the raw member object', () => { + // The old pattern: `return { success: true, member }` + // should be replaced with an explicit projection + expect(source).not.toMatch(/return\s*\{\s*success:\s*true,\s*member\s*\}/) + }) + + it('returns explicit member projection with id', () => { + expect(source).toContain('member._id') + }) + + it('returns explicit member projection with email', () => { + expect(source).toContain('member.email') + }) + + it('returns explicit member projection with name', () => { + expect(source).toContain('member.name') + }) + + it('returns explicit member projection with circle', () => { + expect(source).toContain('member.circle') + }) + + it('returns explicit member projection with status', () => { + expect(source).toContain('member.status') + }) + + it('does NOT expose helcimCustomerId in response', () => { + // helcimCustomerId should not appear in any return statement projection + // (it's OK if it appears elsewhere, e.g. in DB queries) + const returnBlocks = source.match(/return\s*\{[\s\S]*?\n\s*\}/g) || [] + const successReturn = returnBlocks.find(b => b.includes('success: true')) + if (successReturn) { + expect(successReturn).not.toContain('helcimCustomerId') + } + }) + + it('does NOT expose role in response projection', () => { + const returnBlocks = source.match(/return\s*\{[\s\S]*?\n\s*\}/g) || [] + const successReturn = returnBlocks.find(b => b.includes('success: true')) + if (successReturn) { + expect(successReturn).not.toContain('role') + } + }) + + it('does NOT forward error.message to client in catch block', () => { + // The outer catch should not use error.message as statusMessage + expect(source).not.toMatch(/statusMessage:\s*error\.message/) + }) +}) diff --git a/tests/server/api/validation-phase3.test.js b/tests/server/api/validation-phase3.test.js new file mode 100644 index 0000000..41882f4 --- /dev/null +++ b/tests/server/api/validation-phase3.test.js @@ -0,0 +1,559 @@ +import { describe, it, expect } from 'vitest' +import { + helcimCreatePlanSchema, + helcimCustomerSchema, + helcimInitializePaymentSchema, + helcimSubscriptionSchema, + helcimUpdateBillingSchema, + ticketPurchaseSchema, + ticketReserveSchema, + ticketEligibilitySchema, + waitlistSchema, + waitlistDeleteSchema, + cancelRegistrationSchema, + checkRegistrationSchema, + guestRegisterSchema, + eventPaymentSchema, + updateContributionSchema, + peerSupportUpdateSchema, + updatePatchSchema, + seriesTicketPurchaseSchema, + seriesTicketEligibilitySchema, + adminSeriesCreateSchema, + adminSeriesUpdateSchema, + adminSeriesItemUpdateSchema, + adminSeriesTicketsSchema, + adminEventUpdateSchema, + adminMemberCreateSchema +} from '../../../server/utils/schemas.js' + +// --- Helcim schemas --- + +describe('helcimCreatePlanSchema', () => { + it('accepts valid plan data', () => { + const result = helcimCreatePlanSchema.safeParse({ + name: 'Monthly Plan', + amount: '15.00', + frequency: 'monthly' + }) + expect(result.success).toBe(true) + }) + + it('accepts numeric amount', () => { + const result = helcimCreatePlanSchema.safeParse({ + name: 'Plan', + amount: 15, + frequency: 'monthly' + }) + expect(result.success).toBe(true) + }) + + it('rejects missing name', () => { + const result = helcimCreatePlanSchema.safeParse({ + amount: 15, + frequency: 'monthly' + }) + expect(result.success).toBe(false) + }) + + it('rejects missing amount', () => { + const result = helcimCreatePlanSchema.safeParse({ + name: 'Plan', + frequency: 'monthly' + }) + expect(result.success).toBe(false) + }) + + it('strips unknown fields', () => { + const result = helcimCreatePlanSchema.safeParse({ + name: 'Plan', + amount: 15, + frequency: 'monthly', + malicious: 'data' + }) + expect(result.success).toBe(true) + expect(result.data).not.toHaveProperty('malicious') + }) +}) + +describe('helcimCustomerSchema', () => { + it('accepts valid customer data', () => { + const result = helcimCustomerSchema.safeParse({ + name: 'Jane Doe', + email: 'jane@example.com' + }) + expect(result.success).toBe(true) + }) + + it('lowercases email', () => { + const result = helcimCustomerSchema.safeParse({ + name: 'Jane', + email: 'JANE@Example.COM' + }) + expect(result.success).toBe(true) + expect(result.data.email).toBe('jane@example.com') + }) + + it('rejects invalid email', () => { + const result = helcimCustomerSchema.safeParse({ + name: 'Jane', + email: 'not-an-email' + }) + expect(result.success).toBe(false) + }) + + it('strips role field', () => { + const result = helcimCustomerSchema.safeParse({ + name: 'Jane', + email: 'jane@example.com', + role: 'admin' + }) + expect(result.success).toBe(true) + expect(result.data).not.toHaveProperty('role') + }) +}) + +describe('helcimSubscriptionSchema', () => { + it('accepts valid subscription data', () => { + const result = helcimSubscriptionSchema.safeParse({ + customerId: '12345', + contributionTier: '15', + customerCode: 'CST123' + }) + expect(result.success).toBe(true) + }) + + it('rejects invalid contribution tier', () => { + const result = helcimSubscriptionSchema.safeParse({ + customerId: '12345', + contributionTier: '999', + customerCode: 'CST123' + }) + expect(result.success).toBe(false) + }) + + it('rejects missing customerCode', () => { + const result = helcimSubscriptionSchema.safeParse({ + customerId: '12345', + contributionTier: '15' + }) + expect(result.success).toBe(false) + }) +}) + +describe('helcimUpdateBillingSchema', () => { + const validBilling = { + customerId: '123', + billingAddress: { + street: '123 Main St', + city: 'Toronto', + country: 'CA', + postalCode: 'M5V 1A1' + } + } + + it('accepts valid billing data', () => { + const result = helcimUpdateBillingSchema.safeParse(validBilling) + expect(result.success).toBe(true) + }) + + it('rejects missing street', () => { + const result = helcimUpdateBillingSchema.safeParse({ + customerId: '123', + billingAddress: { + city: 'Toronto', + country: 'CA', + postalCode: 'M5V' + } + }) + expect(result.success).toBe(false) + }) + + it('rejects missing billingAddress', () => { + const result = helcimUpdateBillingSchema.safeParse({ + customerId: '123' + }) + expect(result.success).toBe(false) + }) +}) + +// --- Event schemas --- + +describe('ticketPurchaseSchema', () => { + it('accepts valid ticket purchase', () => { + const result = ticketPurchaseSchema.safeParse({ + name: 'Jane', + email: 'jane@example.com' + }) + expect(result.success).toBe(true) + }) + + it('lowercases email', () => { + const result = ticketPurchaseSchema.safeParse({ + name: 'Jane', + email: 'JANE@Example.COM' + }) + expect(result.success).toBe(true) + expect(result.data.email).toBe('jane@example.com') + }) + + it('rejects missing name', () => { + const result = ticketPurchaseSchema.safeParse({ + email: 'jane@example.com' + }) + expect(result.success).toBe(false) + }) + + it('strips unknown fields', () => { + const result = ticketPurchaseSchema.safeParse({ + name: 'Jane', + email: 'jane@example.com', + role: 'admin', + status: 'active' + }) + expect(result.success).toBe(true) + expect(result.data).not.toHaveProperty('role') + expect(result.data).not.toHaveProperty('status') + }) +}) + +describe('ticketReserveSchema', () => { + it('accepts valid email', () => { + const result = ticketReserveSchema.safeParse({ email: 'test@example.com' }) + expect(result.success).toBe(true) + }) + + it('rejects missing email', () => { + const result = ticketReserveSchema.safeParse({}) + expect(result.success).toBe(false) + }) +}) + +describe('waitlistSchema', () => { + it('accepts valid waitlist entry', () => { + const result = waitlistSchema.safeParse({ + email: 'test@example.com', + name: 'Test User' + }) + expect(result.success).toBe(true) + }) + + it('accepts email-only entry', () => { + const result = waitlistSchema.safeParse({ email: 'test@example.com' }) + expect(result.success).toBe(true) + }) + + it('rejects missing email', () => { + const result = waitlistSchema.safeParse({ name: 'Test' }) + expect(result.success).toBe(false) + }) +}) + +describe('guestRegisterSchema', () => { + it('accepts valid guest data', () => { + const result = guestRegisterSchema.safeParse({ + name: 'Guest User', + email: 'guest@example.com' + }) + expect(result.success).toBe(true) + }) + + it('rejects missing name', () => { + const result = guestRegisterSchema.safeParse({ email: 'guest@example.com' }) + expect(result.success).toBe(false) + }) +}) + +describe('eventPaymentSchema', () => { + it('accepts valid payment data', () => { + const result = eventPaymentSchema.safeParse({ + name: 'Payer', + email: 'payer@example.com', + paymentToken: 'tok_abc123' + }) + expect(result.success).toBe(true) + }) + + it('rejects missing paymentToken', () => { + const result = eventPaymentSchema.safeParse({ + name: 'Payer', + email: 'payer@example.com' + }) + expect(result.success).toBe(false) + }) +}) + +// --- Member schemas --- + +describe('updateContributionSchema', () => { + it('accepts valid contribution tier', () => { + const result = updateContributionSchema.safeParse({ contributionTier: '15' }) + expect(result.success).toBe(true) + }) + + it('rejects invalid tier', () => { + const result = updateContributionSchema.safeParse({ contributionTier: '100' }) + expect(result.success).toBe(false) + }) + + it('strips unknown fields', () => { + const result = updateContributionSchema.safeParse({ + contributionTier: '15', + role: 'admin' + }) + expect(result.success).toBe(true) + expect(result.data).not.toHaveProperty('role') + }) +}) + +describe('peerSupportUpdateSchema', () => { + it('accepts valid peer support data', () => { + const result = peerSupportUpdateSchema.safeParse({ + enabled: true, + skillTopics: ['game design', 'business'], + slackUsername: 'jane' + }) + expect(result.success).toBe(true) + }) + + it('accepts empty object', () => { + const result = peerSupportUpdateSchema.safeParse({}) + expect(result.success).toBe(true) + }) + + it('rejects non-array skillTopics', () => { + const result = peerSupportUpdateSchema.safeParse({ + skillTopics: 'not-an-array' + }) + expect(result.success).toBe(false) + }) +}) + +// --- Update schemas --- + +describe('updatePatchSchema', () => { + it('accepts valid update patch', () => { + const result = updatePatchSchema.safeParse({ + content: 'Updated content', + privacy: 'members' + }) + expect(result.success).toBe(true) + }) + + it('accepts empty object (all optional)', () => { + const result = updatePatchSchema.safeParse({}) + expect(result.success).toBe(true) + }) + + it('rejects invalid privacy enum', () => { + const result = updatePatchSchema.safeParse({ privacy: 'invalid' }) + expect(result.success).toBe(false) + }) +}) + +// --- Series schemas --- + +describe('seriesTicketPurchaseSchema', () => { + it('accepts valid series ticket purchase', () => { + const result = seriesTicketPurchaseSchema.safeParse({ + name: 'Buyer', + email: 'buyer@example.com' + }) + expect(result.success).toBe(true) + }) + + it('rejects missing name', () => { + const result = seriesTicketPurchaseSchema.safeParse({ + email: 'buyer@example.com' + }) + expect(result.success).toBe(false) + }) +}) + +// --- Admin schemas --- + +describe('adminSeriesCreateSchema', () => { + it('accepts valid series', () => { + const result = adminSeriesCreateSchema.safeParse({ + id: 'test-series', + title: 'Test Series', + description: 'A test series' + }) + expect(result.success).toBe(true) + }) + + it('rejects missing id', () => { + const result = adminSeriesCreateSchema.safeParse({ + title: 'Test', + description: 'Desc' + }) + expect(result.success).toBe(false) + }) +}) + +describe('adminMemberCreateSchema', () => { + it('accepts valid admin member create', () => { + const result = adminMemberCreateSchema.safeParse({ + name: 'Admin Created', + email: 'admin-created@example.com', + circle: 'founder', + contributionTier: '30' + }) + expect(result.success).toBe(true) + }) + + it('strips role field (mass assignment)', () => { + const result = adminMemberCreateSchema.safeParse({ + name: 'Admin Created', + email: 'admin-created@example.com', + circle: 'founder', + contributionTier: '30', + role: 'admin' + }) + expect(result.success).toBe(true) + expect(result.data).not.toHaveProperty('role') + }) + + it('strips status field (mass assignment)', () => { + const result = adminMemberCreateSchema.safeParse({ + name: 'Admin Created', + email: 'admin-created@example.com', + circle: 'founder', + contributionTier: '30', + status: 'active' + }) + expect(result.success).toBe(true) + expect(result.data).not.toHaveProperty('status') + }) + + it('rejects invalid circle', () => { + const result = adminMemberCreateSchema.safeParse({ + name: 'Admin Created', + email: 'admin-created@example.com', + circle: 'superadmin', + contributionTier: '30' + }) + expect(result.success).toBe(false) + }) +}) + +describe('adminEventUpdateSchema', () => { + const validUpdate = { + title: 'Updated Event', + description: 'Updated description', + startDate: '2026-04-01T10:00:00Z', + endDate: '2026-04-01T12:00:00Z' + } + + it('accepts valid event update', () => { + const result = adminEventUpdateSchema.safeParse(validUpdate) + expect(result.success).toBe(true) + }) + + it('rejects missing title', () => { + const { title, ...rest } = validUpdate + const result = adminEventUpdateSchema.safeParse(rest) + expect(result.success).toBe(false) + }) + + it('accepts optional tickets', () => { + const result = adminEventUpdateSchema.safeParse({ + ...validUpdate, + tickets: { + enabled: true, + public: { + available: true, + price: 25.00 + } + } + }) + expect(result.success).toBe(true) + }) +}) + +// --- Error text forwarding regression tests --- + +describe('error text forwarding regression', () => { + const helcimFiles = [ + 'create-plan.post.js', + 'customer.post.js', + 'customer-code.get.js', + 'initialize-payment.post.js', + 'subscription.post.js', + 'update-billing.post.js', + 'get-or-create-customer.post.js' + ] + + for (const file of helcimFiles) { + it(`${file} does not forward error text to client`, () => { + const source = readFileSync( + resolve(import.meta.dirname, `../../../server/api/helcim/${file}`), + 'utf-8' + ) + // Should not contain template literals that interpolate errorText or error.message into statusMessage + expect(source).not.toMatch(/statusMessage:.*\$\{errorText\}/) + expect(source).not.toMatch(/statusMessage:\s*error\.message\b/) + }) + } + + it('members/update-contribution.post.js does not forward error text', () => { + const source = readFileSync( + resolve(import.meta.dirname, '../../../server/api/members/update-contribution.post.js'), + 'utf-8' + ) + expect(source).not.toMatch(/statusMessage:.*\$\{errorText\}/) + expect(source).not.toMatch(/statusMessage:\s*error\.message\b/) + }) +}) + +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' + +// --- validateBody migration coverage --- + +describe('validateBody migration coverage', () => { + const endpoints = [ + 'helcim/create-plan.post.js', + 'helcim/customer.post.js', + 'helcim/initialize-payment.post.js', + 'helcim/subscription.post.js', + 'helcim/update-billing.post.js', + 'events/[id]/tickets/purchase.post.js', + 'events/[id]/tickets/reserve.post.js', + 'events/[id]/tickets/check-eligibility.post.js', + 'events/[id]/waitlist.post.js', + 'events/[id]/waitlist.delete.js', + 'events/[id]/cancel-registration.post.js', + 'events/[id]/check-registration.post.js', + 'events/[id]/guest-register.post.js', + 'events/[id]/payment.post.js', + 'members/update-contribution.post.js', + 'members/me/peer-support.patch.js', + 'updates/[id].patch.js', + 'series/[id]/tickets/purchase.post.js', + 'series/[id]/tickets/check-eligibility.post.js', + 'admin/series.post.js', + 'admin/series.put.js', + 'admin/series/tickets.put.js', + 'admin/series/[id].put.js', + 'admin/events/[id].put.js', + 'admin/members.post.js' + ] + + for (const endpoint of endpoints) { + it(`${endpoint} uses validateBody instead of raw readBody`, () => { + const source = readFileSync( + resolve(import.meta.dirname, `../../../server/api/${endpoint}`), + 'utf-8' + ) + expect(source).toContain('validateBody(event') + // Should not have bare readBody calls (except inside validateBody itself) + const lines = source.split('\n') + for (const line of lines) { + if (line.includes('readBody(event)') && !line.includes('validateBody')) { + expect.fail(`${endpoint} still has raw readBody: ${line.trim()}`) + } + } + }) + } +})