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.
This commit is contained in:
Jennie Robinson Faber 2026-03-01 17:04:26 +00:00
parent e4813075b7
commit 025c1a180f
38 changed files with 1132 additions and 309 deletions

2
.gitignore vendored
View file

@ -17,7 +17,7 @@ logs
.DS_Store
.fleet
.idea
docs/*
/docs/
# Local env files
.env

View file

@ -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 (150000 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.

View file

@ -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'
})
}
})

View file

@ -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()

View file

@ -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'
})
}
})

View file

@ -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'
})
}
})

View file

@ -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'
})
}
})

View file

@ -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 })

View file

@ -30,6 +30,13 @@ export default defineEventHandler(async (event) => {
})
}
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(
{ memberId: member._id, email: member.email },
@ -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'

View file

@ -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);

View file

@ -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);

View file

@ -6,7 +6,7 @@ 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({
@ -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)) {

View file

@ -8,7 +8,7 @@ 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({
@ -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)) {

View file

@ -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({

View file

@ -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)) {

View file

@ -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)) {

View file

@ -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({

View file

@ -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({

View file

@ -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'
})
}
})

View file

@ -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'
})
}
})

View file

@ -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'
})
}
})

View file

@ -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'
})
}
})

View file

@ -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",
});
}
});

View file

@ -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'
})
}
})

View file

@ -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'
})
}
})

View file

@ -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'
})
}
})

View file

@ -7,26 +7,10 @@ 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
// Update customer billing address in Helcim
@ -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'
})
}
})

View file

@ -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
statusMessage: 'Member creation failed'
})
}
})

View file

@ -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 = {

View file

@ -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",
});
}
});

View file

@ -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 {

View file

@ -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);

View file

@ -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,
};
});

View file

@ -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);

View file

@ -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'])
})

View file

@ -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)
}
})
})
}
})

View file

@ -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/)
})
})

View file

@ -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()}`)
}
}
})
}
})