Day-of-launch deep-dive audit and remediation. 11 issues fixed across security, correctness, and reliability. Tests: 698 → 758 passing (+60), 0 failing, 2 skipped. CRITICAL (security) Fix #1 — HELCIM_API_TOKEN removed from runtimeConfig.public; dead useHelcim.js deleted. Production token MUST BE ROTATED post-deploy (was previously exposed in window.__NUXT__ payload). Fix #2 — /api/helcim/customer gated with origin check + per-IP/email rate limit + magic-link email verification (replaces unauthenticated setAuthCookie). Adds payment-bridge token for paid-tier signup so users can complete Helcim checkout before email verify. New utils: server/utils/{magicLink,rateLimit}.js. UX: signup success copy now prompts user to check email. Fix #3 — /api/events/[id]/payment deleted (dead code with unauth member-spoof bypass — processHelcimPayment was a permanent stub). Removes processHelcimPayment export and eventPaymentSchema. Fix #4 — /api/helcim/initialize-payment re-derives ticket amount server-side via calculateTicketPrice and calculateSeriesTicketPrice. Adds new series_ticket metadata type (was being shoved through event_ticket with seriesId in metadata.eventId). Fix #5 — /api/helcim/customer upgrades existing status:guest members in place rather than rejecting with 409. Lowercases email at lookup; preserves _id so prior event registrations stay linked. HIGH (correctness / reliability) Fix #6 — Daily reconciliation cron via Netlify scheduled function (@daily). New: netlify.toml, netlify/functions/reconcile-payments.mjs, server/api/internal/reconcile-payments.post.js. Shared-secret auth via NUXT_RECONCILE_TOKEN env var. Inline 3-retry exponential backoff on Helcim transactions API. Fix #7 — validateBeforeSave: false on event subdoc saves (waitlist endpoints) to dodge legacy location validators. Fix #8 — /api/series/[id]/tickets/purchase always upserts a guest Member when caller is unauthenticated, mirrors event-ticket flow byte-for-byte. SeriesPassPurchase.vue adds guest-account hint and client auth refresh on signedIn:true response. Fix #9 — /api/members/cancel-subscription leaves status active per ratified bylaws (was pending_payment). Adds lastCancelledAt audit field on Member model. Indirectly fixes false-positive detectStuckPendingPayment admin alert for cancelled members. Fix #10 — /api/auth/verify uses validateBody with strict() Zod schema (verifyMagicLinkSchema, max 2000 chars). Fix #11 — 8 vitest cases for cancel-subscription handler (was uncovered). Specs and audit at docs/superpowers/specs/2026-04-25-fix-*.md and docs/superpowers/plans/2026-04-25-launch-readiness-fixes.md. LAUNCH_READINESS.md updated with new test count, 3 deploy-time tasks (rotate Helcim token, set NUXT_RECONCILE_TOKEN, verify Netlify scheduled function), and Fixed-2026-04-25 fix log.
385 lines
13 KiB
JavaScript
385 lines
13 KiB
JavaScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
|
|
import Member from '../../../server/models/member.js'
|
|
import { createHelcimCustomer } from '../../../server/utils/helcim.js'
|
|
import { sendMagicLink } from '../../../server/utils/magicLink.js'
|
|
import { setAuthCookie, setPaymentBridgeCookie } from '../../../server/utils/auth.js'
|
|
import customerHandler from '../../../server/api/helcim/customer.post.js'
|
|
import { resetRateLimit } from '../../../server/utils/rateLimit.js'
|
|
import { createMockEvent } from '../helpers/createMockEvent.js'
|
|
|
|
// --- Mocks ---
|
|
vi.mock('../../../server/models/member.js', () => ({
|
|
default: { findOne: vi.fn(), create: vi.fn(), findByIdAndUpdate: vi.fn() }
|
|
}))
|
|
vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() }))
|
|
vi.mock('../../../server/utils/helcim.js', () => ({
|
|
createHelcimCustomer: vi.fn()
|
|
}))
|
|
vi.mock('../../../server/utils/magicLink.js', () => ({
|
|
sendMagicLink: vi.fn().mockResolvedValue(undefined)
|
|
}))
|
|
vi.mock('../../../server/utils/auth.js', () => ({
|
|
setAuthCookie: vi.fn(),
|
|
setPaymentBridgeCookie: vi.fn()
|
|
}))
|
|
|
|
// helcimCustomerSchema is auto-imported in the handler — stub it to a passthrough
|
|
vi.stubGlobal('helcimCustomerSchema', {})
|
|
|
|
// Helper to build a same-origin request body+headers
|
|
const ALLOWED_ORIGIN = 'https://ghostguild.test'
|
|
|
|
function build(opts = {}) {
|
|
const {
|
|
origin = ALLOWED_ORIGIN,
|
|
remoteAddress = '127.0.0.1',
|
|
body = {
|
|
name: 'Test User',
|
|
email: 'test@example.com',
|
|
circle: 'community',
|
|
contributionAmount: 0,
|
|
agreedToGuidelines: true
|
|
}
|
|
} = opts
|
|
const headers = {}
|
|
if (origin !== null) headers.origin = origin
|
|
return createMockEvent({
|
|
method: 'POST',
|
|
path: '/api/helcim/customer',
|
|
body,
|
|
headers,
|
|
remoteAddress
|
|
})
|
|
}
|
|
|
|
describe('POST /api/helcim/customer', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
process.env.BASE_URL = ALLOWED_ORIGIN
|
|
resetRateLimit()
|
|
Member.findOne.mockResolvedValue(null)
|
|
Member.create.mockImplementation(async (doc) => ({ _id: 'mem-1', ...doc }))
|
|
Member.findByIdAndUpdate.mockImplementation(async (id, update) => ({
|
|
_id: id,
|
|
...(update?.$set || {})
|
|
}))
|
|
createHelcimCustomer.mockResolvedValue({ id: 'cust-1', customerCode: 'CUST-1' })
|
|
})
|
|
|
|
describe('origin check', () => {
|
|
it('rejects requests with missing Origin header', async () => {
|
|
const event = build({ origin: null })
|
|
await expect(customerHandler(event)).rejects.toMatchObject({
|
|
statusCode: 403,
|
|
statusMessage: 'Invalid origin'
|
|
})
|
|
expect(Member.create).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('rejects requests with foreign Origin header', async () => {
|
|
const event = build({ origin: 'https://attacker.example' })
|
|
await expect(customerHandler(event)).rejects.toMatchObject({
|
|
statusCode: 403,
|
|
statusMessage: 'Invalid origin'
|
|
})
|
|
expect(Member.create).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('accepts requests with matching Origin header', async () => {
|
|
const event = build()
|
|
const result = await customerHandler(event)
|
|
expect(result.success).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('rate limiting', () => {
|
|
it('rate-limits a single IP after 5 signup attempts', async () => {
|
|
// 5 calls succeed (each with a unique email so we don't hit email limit
|
|
// and don't hit the dedupe 409)
|
|
for (let i = 0; i < 5; i++) {
|
|
Member.findOne.mockResolvedValueOnce(null)
|
|
const event = build({
|
|
remoteAddress: '10.0.0.1',
|
|
body: {
|
|
name: 'User',
|
|
email: `u${i}@example.com`,
|
|
circle: 'community',
|
|
contributionAmount: 0,
|
|
agreedToGuidelines: true
|
|
}
|
|
})
|
|
await customerHandler(event)
|
|
}
|
|
// 6th call returns 429
|
|
const event = build({
|
|
remoteAddress: '10.0.0.1',
|
|
body: {
|
|
name: 'User',
|
|
email: 'u6@example.com',
|
|
circle: 'community',
|
|
contributionAmount: 0,
|
|
agreedToGuidelines: true
|
|
}
|
|
})
|
|
await expect(customerHandler(event)).rejects.toMatchObject({
|
|
statusCode: 429
|
|
})
|
|
})
|
|
|
|
it('rate-limits a single email after 3 signup attempts (different IPs)', async () => {
|
|
const email = 'shared@example.com'
|
|
for (let i = 0; i < 3; i++) {
|
|
Member.findOne.mockResolvedValueOnce(null)
|
|
const event = build({
|
|
remoteAddress: `10.0.0.${i + 10}`,
|
|
body: {
|
|
name: 'User',
|
|
email,
|
|
circle: 'community',
|
|
contributionAmount: 0,
|
|
agreedToGuidelines: true
|
|
}
|
|
})
|
|
await customerHandler(event)
|
|
}
|
|
// 4th call returns 429
|
|
const event = build({
|
|
remoteAddress: '10.0.0.99',
|
|
body: {
|
|
name: 'User',
|
|
email,
|
|
circle: 'community',
|
|
contributionAmount: 0,
|
|
agreedToGuidelines: true
|
|
}
|
|
})
|
|
await expect(customerHandler(event)).rejects.toMatchObject({
|
|
statusCode: 429
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('existing-member dedupe (Fix #5)', () => {
|
|
it('brand-new email succeeds and creates a pending_payment member', async () => {
|
|
Member.findOne.mockResolvedValue(null)
|
|
const event = build({
|
|
body: {
|
|
name: 'Brand New',
|
|
email: 'brandnew@example.com',
|
|
circle: 'community',
|
|
contributionAmount: 0,
|
|
agreedToGuidelines: true
|
|
}
|
|
})
|
|
const result = await customerHandler(event)
|
|
expect(result.success).toBe(true)
|
|
expect(result.customerId).toBe('cust-1')
|
|
expect(result.member.status).toBe('pending_payment')
|
|
expect(Member.create).toHaveBeenCalledTimes(1)
|
|
expect(Member.findByIdAndUpdate).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('returns 409 for an existing active member', async () => {
|
|
Member.findOne.mockResolvedValue({
|
|
_id: 'mem-active',
|
|
email: 'active@example.com',
|
|
status: 'active'
|
|
})
|
|
const event = build({
|
|
body: {
|
|
name: 'Active User',
|
|
email: 'active@example.com',
|
|
circle: 'community',
|
|
contributionAmount: 0,
|
|
agreedToGuidelines: true
|
|
}
|
|
})
|
|
await expect(customerHandler(event)).rejects.toMatchObject({
|
|
statusCode: 409,
|
|
statusMessage: 'A member with this email already exists'
|
|
})
|
|
expect(Member.create).not.toHaveBeenCalled()
|
|
expect(Member.findByIdAndUpdate).not.toHaveBeenCalled()
|
|
expect(createHelcimCustomer).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('returns 409 for an existing pending_payment member (re-submit guard)', async () => {
|
|
Member.findOne.mockResolvedValue({
|
|
_id: 'mem-pending',
|
|
email: 'pending@example.com',
|
|
status: 'pending_payment'
|
|
})
|
|
const event = build({
|
|
body: {
|
|
name: 'Pending User',
|
|
email: 'pending@example.com',
|
|
circle: 'community',
|
|
contributionAmount: 0,
|
|
agreedToGuidelines: true
|
|
}
|
|
})
|
|
await expect(customerHandler(event)).rejects.toMatchObject({
|
|
statusCode: 409
|
|
})
|
|
expect(Member.create).not.toHaveBeenCalled()
|
|
expect(Member.findByIdAndUpdate).not.toHaveBeenCalled()
|
|
expect(createHelcimCustomer).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('returns 409 for suspended and cancelled members', async () => {
|
|
for (const status of ['suspended', 'cancelled']) {
|
|
vi.clearAllMocks()
|
|
resetRateLimit()
|
|
Member.findOne.mockResolvedValue({ _id: `mem-${status}`, status })
|
|
const event = build({
|
|
body: {
|
|
name: 'User',
|
|
email: `${status}@example.com`,
|
|
circle: 'community',
|
|
contributionAmount: 0,
|
|
agreedToGuidelines: true
|
|
}
|
|
})
|
|
await expect(customerHandler(event)).rejects.toMatchObject({
|
|
statusCode: 409
|
|
})
|
|
expect(Member.create).not.toHaveBeenCalled()
|
|
expect(Member.findByIdAndUpdate).not.toHaveBeenCalled()
|
|
expect(createHelcimCustomer).not.toHaveBeenCalled()
|
|
}
|
|
})
|
|
|
|
it('upgrades an existing guest member in place (preserves _id)', async () => {
|
|
const guestId = 'guest-1'
|
|
Member.findOne.mockResolvedValue({
|
|
_id: guestId,
|
|
email: 'guest@example.com',
|
|
name: 'Old Guest Name',
|
|
circle: 'community',
|
|
contributionAmount: 0,
|
|
status: 'guest'
|
|
})
|
|
const event = build({
|
|
body: {
|
|
name: 'New Member Name',
|
|
email: 'guest@example.com',
|
|
circle: 'founder',
|
|
contributionAmount: 25,
|
|
agreedToGuidelines: true
|
|
}
|
|
})
|
|
const result = await customerHandler(event)
|
|
|
|
// No new member doc created — existing guest is reused.
|
|
expect(Member.create).not.toHaveBeenCalled()
|
|
|
|
// Helcim customer created for the upgraded member.
|
|
expect(createHelcimCustomer).toHaveBeenCalledWith({
|
|
customerType: 'PERSON',
|
|
contactName: 'New Member Name',
|
|
email: 'guest@example.com'
|
|
})
|
|
|
|
// findByIdAndUpdate called with guest's _id (preservation) and the form fields.
|
|
expect(Member.findByIdAndUpdate).toHaveBeenCalledTimes(1)
|
|
const [updateId, updatePayload, updateOpts] = Member.findByIdAndUpdate.mock.calls[0]
|
|
expect(updateId).toBe(guestId)
|
|
expect(updatePayload.$set).toMatchObject({
|
|
name: 'New Member Name',
|
|
circle: 'founder',
|
|
contributionAmount: 25,
|
|
helcimCustomerId: 'cust-1',
|
|
status: 'pending_payment'
|
|
})
|
|
expect(updatePayload.$set['agreement.acceptedAt']).toBeInstanceOf(Date)
|
|
expect(updateOpts).toMatchObject({ new: true, runValidators: false })
|
|
|
|
// Magic link still issued, paid-tier bridge cookie still set.
|
|
expect(sendMagicLink).toHaveBeenCalledWith(
|
|
'guest@example.com',
|
|
expect.objectContaining({ subject: 'Verify your Ghost Guild signup' })
|
|
)
|
|
expect(setPaymentBridgeCookie).toHaveBeenCalled()
|
|
expect(setAuthCookie).not.toHaveBeenCalled()
|
|
|
|
// Response shape mirrors new-signup case AND surfaces the preserved _id.
|
|
expect(result.success).toBe(true)
|
|
expect(result.member.id).toBe(guestId)
|
|
expect(result.member.status).toBe('pending_payment')
|
|
expect(result.customerId).toBe('cust-1')
|
|
})
|
|
|
|
it('lowercases mixed-case email at the existence lookup', async () => {
|
|
Member.findOne.mockResolvedValue({
|
|
_id: 'guest-mixed',
|
|
email: 'foo@example.com',
|
|
name: 'Existing Guest',
|
|
circle: 'community',
|
|
contributionAmount: 0,
|
|
status: 'guest'
|
|
})
|
|
const event = build({
|
|
body: {
|
|
name: 'Foo Bar',
|
|
email: 'Foo@Example.com',
|
|
circle: 'community',
|
|
contributionAmount: 0,
|
|
agreedToGuidelines: true
|
|
}
|
|
})
|
|
await customerHandler(event)
|
|
expect(Member.findOne).toHaveBeenCalledWith({ email: 'foo@example.com' })
|
|
expect(Member.findByIdAndUpdate).toHaveBeenCalledTimes(1)
|
|
expect(Member.create).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
describe('no auth cookie + magic link', () => {
|
|
it('does not set auth-token cookie on free-tier signup', async () => {
|
|
const event = build()
|
|
await customerHandler(event)
|
|
expect(setAuthCookie).not.toHaveBeenCalled()
|
|
const cookieHeader = event._testSetHeaders['set-cookie']
|
|
const cookies = Array.isArray(cookieHeader) ? cookieHeader.join(';') : (cookieHeader || '')
|
|
expect(cookies).not.toContain('auth-token=')
|
|
})
|
|
|
|
it('sends a magic link to the new member email', async () => {
|
|
const event = build({
|
|
body: {
|
|
name: 'New User',
|
|
email: 'newuser@example.com',
|
|
circle: 'community',
|
|
contributionAmount: 0,
|
|
agreedToGuidelines: true
|
|
}
|
|
})
|
|
await customerHandler(event)
|
|
expect(sendMagicLink).toHaveBeenCalledWith(
|
|
'newuser@example.com',
|
|
expect.objectContaining({ subject: 'Verify your Ghost Guild signup' })
|
|
)
|
|
})
|
|
|
|
it('sets a payment-bridge cookie on paid-tier signup so checkout can proceed', async () => {
|
|
const event = build({
|
|
body: {
|
|
name: 'Paid User',
|
|
email: 'paid@example.com',
|
|
circle: 'community',
|
|
contributionAmount: 25,
|
|
agreedToGuidelines: true
|
|
}
|
|
})
|
|
await customerHandler(event)
|
|
expect(setPaymentBridgeCookie).toHaveBeenCalled()
|
|
expect(sendMagicLink).toHaveBeenCalledWith(
|
|
'paid@example.com',
|
|
expect.objectContaining({ subject: 'Verify your Ghost Guild signup' })
|
|
)
|
|
// still no full session cookie
|
|
expect(setAuthCookie).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
})
|