feat(launch): security and correctness fixes for 2026-05-01 launch
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.
This commit is contained in:
parent
0f2f1d1cbf
commit
208638e374
37 changed files with 1980 additions and 340 deletions
|
|
@ -1,5 +1,10 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
import jwt from 'jsonwebtoken'
|
||||
import Member from '../../../server/models/member.js'
|
||||
import verifyHandler from '../../../server/api/auth/verify.post.js'
|
||||
import { createMockEvent } from '../helpers/createMockEvent.js'
|
||||
|
||||
vi.mock('../../../server/models/member.js', () => ({
|
||||
default: { findById: vi.fn(), findByIdAndUpdate: vi.fn() }
|
||||
}))
|
||||
|
|
@ -15,11 +20,6 @@ vi.mock('jsonwebtoken', () => ({
|
|||
}
|
||||
}))
|
||||
|
||||
import jwt from 'jsonwebtoken'
|
||||
import Member from '../../../server/models/member.js'
|
||||
import verifyHandler from '../../../server/api/auth/verify.post.js'
|
||||
import { createMockEvent } from '../helpers/createMockEvent.js'
|
||||
|
||||
const baseMember = {
|
||||
_id: 'member-123',
|
||||
email: 'test@example.com',
|
||||
|
|
@ -44,10 +44,94 @@ describe('auth verify endpoint', () => {
|
|||
|
||||
await expect(verifyHandler(event)).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Token is required'
|
||||
statusMessage: 'Validation failed'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects non-string number token with 400 before jwt.verify', async () => {
|
||||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
path: '/api/auth/verify',
|
||||
body: { token: 123 }
|
||||
})
|
||||
|
||||
await expect(verifyHandler(event)).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Validation failed'
|
||||
})
|
||||
expect(jwt.verify).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects non-string boolean token with 400 before jwt.verify', async () => {
|
||||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
path: '/api/auth/verify',
|
||||
body: { token: true }
|
||||
})
|
||||
|
||||
await expect(verifyHandler(event)).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Validation failed'
|
||||
})
|
||||
expect(jwt.verify).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects empty string token with 400 before jwt.verify', async () => {
|
||||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
path: '/api/auth/verify',
|
||||
body: { token: '' }
|
||||
})
|
||||
|
||||
await expect(verifyHandler(event)).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Validation failed'
|
||||
})
|
||||
expect(jwt.verify).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects oversized token (>2000 chars) with 400 before jwt.verify', async () => {
|
||||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
path: '/api/auth/verify',
|
||||
body: { token: 'x'.repeat(2001) }
|
||||
})
|
||||
|
||||
await expect(verifyHandler(event)).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Validation failed'
|
||||
})
|
||||
expect(jwt.verify).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects null body with 400 before jwt.verify', async () => {
|
||||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
path: '/api/auth/verify',
|
||||
body: null
|
||||
})
|
||||
|
||||
await expect(verifyHandler(event)).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Validation failed'
|
||||
})
|
||||
expect(jwt.verify).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects unknown extra keys with 400 before jwt.verify (strict mode)', async () => {
|
||||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
path: '/api/auth/verify',
|
||||
body: { token: 'valid-token', email: 'extra@example.com' }
|
||||
})
|
||||
|
||||
await expect(verifyHandler(event)).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Validation failed'
|
||||
})
|
||||
expect(jwt.verify).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects invalid JWT with 401', async () => {
|
||||
jwt.verify.mockImplementation(() => { throw new Error('invalid') })
|
||||
|
||||
|
|
|
|||
201
tests/server/api/cancel-subscription.test.js
Normal file
201
tests/server/api/cancel-subscription.test.js
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
import Member from '../../../server/models/member.js'
|
||||
import { cancelHelcimSubscription } from '../../../server/utils/helcim.js'
|
||||
import handler from '../../../server/api/members/cancel-subscription.post.js'
|
||||
import { createMockEvent } from '../helpers/createMockEvent.js'
|
||||
|
||||
vi.mock('../../../server/models/member.js', () => ({
|
||||
default: { findByIdAndUpdate: vi.fn() }
|
||||
}))
|
||||
vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() }))
|
||||
vi.mock('../../../server/utils/auth.js', () => ({ requireAuth: vi.fn() }))
|
||||
vi.mock('../../../server/utils/helcim.js', () => ({
|
||||
cancelHelcimSubscription: vi.fn(),
|
||||
}))
|
||||
|
||||
// Nitro auto-imports — stub as globals
|
||||
const logActivityMock = vi.fn()
|
||||
vi.stubGlobal('logActivity', logActivityMock)
|
||||
|
||||
function setMember(mockMember) {
|
||||
globalThis.requireAuth = vi.fn().mockResolvedValue(mockMember)
|
||||
}
|
||||
|
||||
function buildEvent() {
|
||||
return createMockEvent({
|
||||
method: 'POST',
|
||||
path: '/api/members/cancel-subscription',
|
||||
body: {},
|
||||
})
|
||||
}
|
||||
|
||||
describe('cancel-subscription endpoint', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('happy path: active paid member → cancels Helcim, drops to free tier (status stays active), returns success', async () => {
|
||||
const mockMember = {
|
||||
_id: 'member-1',
|
||||
status: 'active',
|
||||
contributionAmount: 15,
|
||||
helcimSubscriptionId: 'sub-1',
|
||||
}
|
||||
setMember(mockMember)
|
||||
cancelHelcimSubscription.mockResolvedValue({})
|
||||
Member.findByIdAndUpdate.mockResolvedValue({})
|
||||
|
||||
const result = await handler(buildEvent())
|
||||
|
||||
expect(cancelHelcimSubscription).toHaveBeenCalledWith('sub-1')
|
||||
expect(Member.findByIdAndUpdate).toHaveBeenCalledWith(
|
||||
'member-1',
|
||||
expect.objectContaining({
|
||||
$set: expect.objectContaining({
|
||||
status: 'active',
|
||||
contributionAmount: 0,
|
||||
helcimSubscriptionId: null,
|
||||
paymentMethod: 'none',
|
||||
}),
|
||||
$unset: expect.objectContaining({ nextBillingDate: 1 }),
|
||||
}),
|
||||
{ runValidators: false }
|
||||
)
|
||||
// Per Fix #9: lastCancelledAt must be set as a Date
|
||||
const updateArg = Member.findByIdAndUpdate.mock.calls[0][1]
|
||||
expect(updateArg.$set.lastCancelledAt).toBeInstanceOf(Date)
|
||||
expect(result).toEqual(expect.objectContaining({
|
||||
success: true,
|
||||
message: 'Subscription cancelled successfully',
|
||||
status: 'active',
|
||||
contributionAmount: 0,
|
||||
}))
|
||||
})
|
||||
|
||||
it('persists subscriptionEndDate as a Date instance', async () => {
|
||||
setMember({
|
||||
_id: 'member-2',
|
||||
status: 'active',
|
||||
contributionAmount: 5,
|
||||
helcimSubscriptionId: 'sub-2',
|
||||
})
|
||||
cancelHelcimSubscription.mockResolvedValue({})
|
||||
Member.findByIdAndUpdate.mockResolvedValue({})
|
||||
|
||||
await handler(buildEvent())
|
||||
|
||||
const call = Member.findByIdAndUpdate.mock.calls[0]
|
||||
expect(call[1].$set.subscriptionEndDate).toBeInstanceOf(Date)
|
||||
})
|
||||
|
||||
it('emits subscription_cancelled activity log entry', async () => {
|
||||
setMember({
|
||||
_id: 'member-3',
|
||||
status: 'active',
|
||||
contributionAmount: 15,
|
||||
helcimSubscriptionId: 'sub-3',
|
||||
})
|
||||
cancelHelcimSubscription.mockResolvedValue({})
|
||||
Member.findByIdAndUpdate.mockResolvedValue({})
|
||||
|
||||
await handler(buildEvent())
|
||||
|
||||
expect(logActivityMock).toHaveBeenCalledWith(
|
||||
'member-3',
|
||||
'subscription_cancelled',
|
||||
expect.objectContaining({ effectiveDate: expect.any(String) })
|
||||
)
|
||||
})
|
||||
|
||||
it('free-tier member (contributionAmount=0): no Helcim call, no DB write, returns no-op success', async () => {
|
||||
const mockMember = {
|
||||
_id: 'member-4',
|
||||
status: 'active',
|
||||
contributionAmount: 0,
|
||||
helcimSubscriptionId: null,
|
||||
}
|
||||
setMember(mockMember)
|
||||
|
||||
const result = await handler(buildEvent())
|
||||
|
||||
expect(cancelHelcimSubscription).not.toHaveBeenCalled()
|
||||
expect(Member.findByIdAndUpdate).not.toHaveBeenCalled()
|
||||
expect(logActivityMock).not.toHaveBeenCalled()
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
message: 'No active subscription to cancel',
|
||||
status: 'active',
|
||||
contributionAmount: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('member without helcimSubscriptionId (data inconsistency): no Helcim call, no DB write', async () => {
|
||||
setMember({
|
||||
_id: 'member-5',
|
||||
status: 'active',
|
||||
contributionAmount: 15,
|
||||
helcimSubscriptionId: null,
|
||||
})
|
||||
|
||||
const result = await handler(buildEvent())
|
||||
|
||||
expect(cancelHelcimSubscription).not.toHaveBeenCalled()
|
||||
expect(Member.findByIdAndUpdate).not.toHaveBeenCalled()
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.message).toBe('No active subscription to cancel')
|
||||
})
|
||||
|
||||
it('Helcim cancel failure: swallows error, still updates Member to free tier (status stays active)', async () => {
|
||||
setMember({
|
||||
_id: 'member-6',
|
||||
status: 'active',
|
||||
contributionAmount: 15,
|
||||
helcimSubscriptionId: 'sub-6',
|
||||
})
|
||||
cancelHelcimSubscription.mockRejectedValue(new Error('Helcim 503'))
|
||||
Member.findByIdAndUpdate.mockResolvedValue({})
|
||||
|
||||
const result = await handler(buildEvent())
|
||||
|
||||
expect(cancelHelcimSubscription).toHaveBeenCalledWith('sub-6')
|
||||
expect(Member.findByIdAndUpdate).toHaveBeenCalledWith(
|
||||
'member-6',
|
||||
expect.objectContaining({
|
||||
$set: expect.objectContaining({ status: 'active', helcimSubscriptionId: null }),
|
||||
}),
|
||||
{ runValidators: false }
|
||||
)
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.status).toBe('active')
|
||||
})
|
||||
|
||||
it('unauthenticated: requireAuth throws → handler rejects with that statusCode', async () => {
|
||||
globalThis.requireAuth = vi.fn().mockRejectedValue(
|
||||
Object.assign(new Error('Unauthorized'), { statusCode: 401 })
|
||||
)
|
||||
|
||||
await expect(handler(buildEvent())).rejects.toMatchObject({
|
||||
statusCode: 401,
|
||||
})
|
||||
|
||||
expect(cancelHelcimSubscription).not.toHaveBeenCalled()
|
||||
expect(Member.findByIdAndUpdate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Mongo update failure: rejects with 500', async () => {
|
||||
setMember({
|
||||
_id: 'member-7',
|
||||
status: 'active',
|
||||
contributionAmount: 15,
|
||||
helcimSubscriptionId: 'sub-7',
|
||||
})
|
||||
cancelHelcimSubscription.mockResolvedValue({})
|
||||
Member.findByIdAndUpdate.mockRejectedValue(new Error('Mongo down'))
|
||||
|
||||
await expect(handler(buildEvent())).rejects.toMatchObject({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Mongo down',
|
||||
})
|
||||
})
|
||||
})
|
||||
49
tests/server/api/event-save-validators.test.js
Normal file
49
tests/server/api/event-save-validators.test.js
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { readFileSync, existsSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
const eventsDir = resolve(import.meta.dirname, '../../../server/api/events/[id]')
|
||||
|
||||
describe('waitlist.post.js bypasses validators on event.save()', () => {
|
||||
const source = readFileSync(resolve(eventsDir, 'waitlist.post.js'), 'utf-8')
|
||||
|
||||
it('calls eventData.save with validateBeforeSave: false', () => {
|
||||
expect(source).toContain('eventData.save({ validateBeforeSave: false })')
|
||||
})
|
||||
|
||||
it('does not contain a bare eventData.save() call', () => {
|
||||
expect(source).not.toMatch(/eventData\.save\(\s*\)/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('waitlist.delete.js bypasses validators on event.save()', () => {
|
||||
const source = readFileSync(resolve(eventsDir, 'waitlist.delete.js'), 'utf-8')
|
||||
|
||||
it('calls eventData.save with validateBeforeSave: false', () => {
|
||||
expect(source).toContain('eventData.save({ validateBeforeSave: false })')
|
||||
})
|
||||
|
||||
it('does not contain a bare eventData.save() call', () => {
|
||||
expect(source).not.toMatch(/eventData\.save\(\s*\)/)
|
||||
})
|
||||
})
|
||||
|
||||
// payment.post.js cases are handled by Fix #3 (file deletion).
|
||||
// If the file still exists, it should also pass the validators bypass.
|
||||
describe.skipIf(!existsSync(resolve(eventsDir, 'payment.post.js')))(
|
||||
'payment.post.js bypasses validators on event.save()',
|
||||
() => {
|
||||
const source = existsSync(resolve(eventsDir, 'payment.post.js'))
|
||||
? readFileSync(resolve(eventsDir, 'payment.post.js'), 'utf-8')
|
||||
: ''
|
||||
|
||||
it('has exactly two eventData.save({ validateBeforeSave: false }) calls', () => {
|
||||
const matches = source.match(/eventData\.save\(\{\s*validateBeforeSave:\s*false\s*\}\)/g) || []
|
||||
expect(matches.length).toBe(2)
|
||||
})
|
||||
|
||||
it('does not contain a bare eventData.save() call', () => {
|
||||
expect(source).not.toMatch(/eventData\.save\(\s*\)/)
|
||||
})
|
||||
}
|
||||
)
|
||||
31
tests/server/api/events/payment-deletion.test.js
Normal file
31
tests/server/api/events/payment-deletion.test.js
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
/**
|
||||
* Regression: `events/[id]/payment.post.js` was deleted because its
|
||||
* unauthenticated POST allowed any caller to spam-register an existing
|
||||
* member to any paid event by supplying their email. See
|
||||
* docs/superpowers/specs/2026-04-25-fix-3.md.
|
||||
*
|
||||
* With the route file gone, Nitro's filesystem router will not register
|
||||
* a handler at `/api/events/{id}/payment`, so a POST returns 404 — the
|
||||
* spam-register attack surface no longer exists at the network layer.
|
||||
*/
|
||||
describe('events/[id]/payment route deletion', () => {
|
||||
it('the payment.post.js route file no longer exists', () => {
|
||||
const routePath = resolve(
|
||||
import.meta.dirname,
|
||||
'../../../../server/api/events/[id]/payment.post.js'
|
||||
)
|
||||
expect(existsSync(routePath)).toBe(false)
|
||||
})
|
||||
|
||||
it('the secure replacement at tickets/purchase.post.js still exists', () => {
|
||||
const replacementPath = resolve(
|
||||
import.meta.dirname,
|
||||
'../../../../server/api/events/[id]/tickets/purchase.post.js'
|
||||
)
|
||||
expect(existsSync(replacementPath)).toBe(true)
|
||||
})
|
||||
})
|
||||
385
tests/server/api/helcim-customer.test.js
Normal file
385
tests/server/api/helcim-customer.test.js
Normal file
|
|
@ -0,0 +1,385 @@
|
|||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -1,14 +1,23 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
import { requireAuth } from '../../../server/utils/auth.js'
|
||||
import { requireAuth, getOptionalMember } from '../../../server/utils/auth.js'
|
||||
import { validateBody as importedValidateBody } from '../../../server/utils/validateBody.js'
|
||||
import { loadPublicEvent } from '../../../server/utils/loadEvent.js'
|
||||
import Member from '../../../server/models/member.js'
|
||||
import Series from '../../../server/models/series.js'
|
||||
import initPaymentHandler from '../../../server/api/helcim/initialize-payment.post.js'
|
||||
import verifyPaymentHandler from '../../../server/api/helcim/verify-payment.post.js'
|
||||
import { createMockEvent } from '../helpers/createMockEvent.js'
|
||||
|
||||
vi.mock('../../../server/utils/auth.js', () => ({ requireAuth: vi.fn() }))
|
||||
vi.mock('../../../server/utils/auth.js', () => ({
|
||||
requireAuth: vi.fn(),
|
||||
getOptionalMember: vi.fn()
|
||||
}))
|
||||
vi.mock('../../../server/utils/validateBody.js', () => ({ validateBody: vi.fn() }))
|
||||
vi.mock('../../../server/utils/schemas.js', () => ({ paymentVerifySchema: {} }))
|
||||
vi.mock('../../../server/utils/loadEvent.js', () => ({ loadPublicEvent: vi.fn() }))
|
||||
vi.mock('../../../server/models/member.js', () => ({ default: { findOne: vi.fn() } }))
|
||||
vi.mock('../../../server/models/series.js', () => ({ default: { findOne: vi.fn() } }))
|
||||
|
||||
// helcimInitializePaymentSchema is a Nitro auto-import used by validateBody
|
||||
vi.stubGlobal('helcimInitializePaymentSchema', {})
|
||||
|
|
@ -19,6 +28,9 @@ vi.stubGlobal('fetch', mockFetch)
|
|||
describe('initialize-payment endpoint', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
getOptionalMember.mockResolvedValue(null)
|
||||
Member.findOne.mockResolvedValue(null)
|
||||
Series.findOne.mockResolvedValue(null)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -31,6 +43,11 @@ describe('initialize-payment endpoint', () => {
|
|||
metadata: { type: 'event_ticket', eventTitle: 'Test Event', eventId: 'evt-1' }
|
||||
}
|
||||
globalThis.validateBody.mockResolvedValue(body)
|
||||
loadPublicEvent.mockResolvedValue({
|
||||
_id: 'evt-1',
|
||||
title: 'Test Event',
|
||||
tickets: { enabled: true, public: { available: true, price: 2500 } }
|
||||
})
|
||||
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
|
|
@ -75,6 +92,11 @@ describe('initialize-payment endpoint', () => {
|
|||
metadata: { type: 'event_ticket', eventTitle: 'Workshop', eventId: 'evt-2' }
|
||||
}
|
||||
globalThis.validateBody.mockResolvedValue(body)
|
||||
loadPublicEvent.mockResolvedValue({
|
||||
_id: 'evt-2',
|
||||
title: 'Workshop',
|
||||
tickets: { enabled: true, public: { available: true, price: 1500 } }
|
||||
})
|
||||
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
|
|
@ -92,9 +114,152 @@ describe('initialize-payment endpoint', () => {
|
|||
expect(result).toEqual({
|
||||
success: true,
|
||||
checkoutToken: 'ct-abc',
|
||||
secretToken: 'st-xyz'
|
||||
secretToken: 'st-xyz',
|
||||
amount: 1500
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores client-supplied amount and re-derives event_ticket price server-side', async () => {
|
||||
const body = {
|
||||
amount: 1, // tampered low value
|
||||
metadata: { type: 'event_ticket', eventId: 'evt-x' }
|
||||
}
|
||||
globalThis.validateBody.mockResolvedValue(body)
|
||||
loadPublicEvent.mockResolvedValue({
|
||||
_id: 'evt-x',
|
||||
title: 'Pricey Workshop',
|
||||
tickets: { enabled: true, public: { available: true, price: 5000 } }
|
||||
})
|
||||
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
text: async () => JSON.stringify({ checkoutToken: 'ct-1', secretToken: 'st-1' })
|
||||
})
|
||||
|
||||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
path: '/api/helcim/initialize-payment',
|
||||
body
|
||||
})
|
||||
|
||||
const result = await initPaymentHandler(event)
|
||||
|
||||
// Verify the fetch to Helcim was called with the server-derived amount, not body.amount
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1)
|
||||
const [, init] = mockFetch.mock.calls[0]
|
||||
const sentBody = JSON.parse(init.body)
|
||||
expect(sentBody.amount).toBe(5000)
|
||||
expect(sentBody.amount).not.toBe(1)
|
||||
expect(sentBody.paymentType).toBe('purchase')
|
||||
expect(result.amount).toBe(5000)
|
||||
})
|
||||
|
||||
it('returns 400 when event_ticket metadata is missing eventId', async () => {
|
||||
const body = { amount: 25, metadata: { type: 'event_ticket' } }
|
||||
globalThis.validateBody.mockResolvedValue(body)
|
||||
|
||||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
path: '/api/helcim/initialize-payment',
|
||||
body
|
||||
})
|
||||
|
||||
await expect(initPaymentHandler(event)).rejects.toMatchObject({
|
||||
statusCode: 400
|
||||
})
|
||||
})
|
||||
|
||||
it('returns 400 when series_ticket metadata is missing seriesId', async () => {
|
||||
const body = { amount: 50, metadata: { type: 'series_ticket' } }
|
||||
globalThis.validateBody.mockResolvedValue(body)
|
||||
|
||||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
path: '/api/helcim/initialize-payment',
|
||||
body
|
||||
})
|
||||
|
||||
await expect(initPaymentHandler(event)).rejects.toMatchObject({
|
||||
statusCode: 400
|
||||
})
|
||||
})
|
||||
|
||||
it('re-derives series_ticket price via Series.findOne + calculateSeriesTicketPrice', async () => {
|
||||
const body = {
|
||||
amount: 100, // tampered
|
||||
metadata: { type: 'series_ticket', seriesId: 'ser-x' }
|
||||
}
|
||||
globalThis.validateBody.mockResolvedValue(body)
|
||||
Series.findOne.mockResolvedValue({
|
||||
_id: 'ser-x',
|
||||
title: 'Coop Foundations',
|
||||
tickets: { enabled: true, public: { available: true, price: 7500 } }
|
||||
})
|
||||
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
text: async () => JSON.stringify({ checkoutToken: 'ct-s', secretToken: 'st-s' })
|
||||
})
|
||||
|
||||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
path: '/api/helcim/initialize-payment',
|
||||
body
|
||||
})
|
||||
|
||||
const result = await initPaymentHandler(event)
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1)
|
||||
const [, init] = mockFetch.mock.calls[0]
|
||||
const sentBody = JSON.parse(init.body)
|
||||
expect(sentBody.amount).toBe(7500)
|
||||
expect(sentBody.paymentType).toBe('purchase')
|
||||
expect(result.amount).toBe(7500)
|
||||
expect(Series.findOne).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses member pricing when metadata.email matches an active member', async () => {
|
||||
const body = {
|
||||
amount: 5000,
|
||||
metadata: { type: 'event_ticket', eventId: 'evt-m', email: '[email protected]' }
|
||||
}
|
||||
globalThis.validateBody.mockResolvedValue(body)
|
||||
Member.findOne.mockResolvedValue({
|
||||
_id: 'm-1',
|
||||
email: '[email protected]',
|
||||
status: 'active',
|
||||
circle: 'community'
|
||||
})
|
||||
loadPublicEvent.mockResolvedValue({
|
||||
_id: 'evt-m',
|
||||
title: 'Member Event',
|
||||
tickets: {
|
||||
enabled: true,
|
||||
member: { available: true, isFree: true },
|
||||
public: { available: true, price: 5000 }
|
||||
}
|
||||
})
|
||||
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
text: async () => JSON.stringify({ checkoutToken: 'ct-m', secretToken: 'st-m' })
|
||||
})
|
||||
|
||||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
path: '/api/helcim/initialize-payment',
|
||||
body
|
||||
})
|
||||
|
||||
const result = await initPaymentHandler(event)
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1)
|
||||
const [, init] = mockFetch.mock.calls[0]
|
||||
const sentBody = JSON.parse(init.body)
|
||||
expect(sentBody.amount).toBe(0)
|
||||
expect(sentBody.paymentType).toBe('verify')
|
||||
expect(result.amount).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('verify-payment endpoint', () => {
|
||||
|
|
|
|||
|
|
@ -12,7 +12,10 @@ vi.mock('../../../server/models/member.js', () => ({
|
|||
default: { findOneAndUpdate: vi.fn(), findOne: vi.fn() }
|
||||
}))
|
||||
vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() }))
|
||||
vi.mock('../../../server/utils/auth.js', () => ({ requireAuth: vi.fn() }))
|
||||
vi.mock('../../../server/utils/auth.js', () => ({
|
||||
requireAuth: vi.fn(),
|
||||
getPaymentBridgeMember: vi.fn().mockResolvedValue(null)
|
||||
}))
|
||||
vi.mock('../../../server/utils/slack.ts', () => ({
|
||||
getSlackService: vi.fn().mockReturnValue(null)
|
||||
}))
|
||||
|
|
|
|||
269
tests/server/api/reconcile-payments-route.test.js
Normal file
269
tests/server/api/reconcile-payments-route.test.js
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
import Member from '../../../server/models/member.js'
|
||||
import Payment from '../../../server/models/payment.js'
|
||||
import { listHelcimCustomerTransactions } from '../../../server/utils/helcim.js'
|
||||
import { upsertPaymentFromHelcim } from '../../../server/utils/payments.js'
|
||||
import reconcileHandler from '../../../server/api/internal/reconcile-payments.post.js'
|
||||
import { createMockEvent } from '../helpers/createMockEvent.js'
|
||||
|
||||
vi.mock('../../../server/models/member.js', () => ({
|
||||
default: { find: vi.fn() }
|
||||
}))
|
||||
vi.mock('../../../server/models/payment.js', () => ({
|
||||
default: { findOne: vi.fn() }
|
||||
}))
|
||||
vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() }))
|
||||
vi.mock('../../../server/utils/helcim.js', () => ({
|
||||
listHelcimCustomerTransactions: vi.fn()
|
||||
}))
|
||||
vi.mock('../../../server/utils/payments.js', () => ({
|
||||
upsertPaymentFromHelcim: vi.fn()
|
||||
}))
|
||||
|
||||
// Override useRuntimeConfig from setup.js for these tests
|
||||
const RECONCILE_TOKEN = 'test-reconcile-secret-32-characters-long-xx'
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('useRuntimeConfig', () => ({
|
||||
jwtSecret: 'test-jwt-secret',
|
||||
helcimApiToken: 'test-helcim-token',
|
||||
reconcileToken: RECONCILE_TOKEN
|
||||
}))
|
||||
})
|
||||
|
||||
function leanResolver(value) {
|
||||
return { lean: vi.fn().mockResolvedValue(value) }
|
||||
}
|
||||
|
||||
describe('POST /api/internal/reconcile-payments', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('rejects requests without the shared-secret token', async () => {
|
||||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
path: '/api/internal/reconcile-payments'
|
||||
})
|
||||
|
||||
await expect(reconcileHandler(event)).rejects.toMatchObject({
|
||||
statusCode: 401,
|
||||
statusMessage: 'Unauthorized'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects requests with the wrong token', async () => {
|
||||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
path: '/api/internal/reconcile-payments',
|
||||
headers: { 'x-reconcile-token': 'not-the-right-secret' }
|
||||
})
|
||||
|
||||
await expect(reconcileHandler(event)).rejects.toMatchObject({
|
||||
statusCode: 401,
|
||||
statusMessage: 'Unauthorized'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects when reconcileToken is not configured on the server', async () => {
|
||||
vi.stubGlobal('useRuntimeConfig', () => ({
|
||||
jwtSecret: 'test-jwt-secret',
|
||||
helcimApiToken: 'test-helcim-token',
|
||||
reconcileToken: ''
|
||||
}))
|
||||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
path: '/api/internal/reconcile-payments',
|
||||
headers: { 'x-reconcile-token': 'anything' }
|
||||
})
|
||||
|
||||
await expect(reconcileHandler(event)).rejects.toMatchObject({
|
||||
statusCode: 401
|
||||
})
|
||||
})
|
||||
|
||||
it('queries members with helcimCustomerId and returns a summary', async () => {
|
||||
Member.find.mockReturnValue(leanResolver([
|
||||
{
|
||||
_id: 'm1',
|
||||
email: 'a@example.com',
|
||||
helcimCustomerId: 'cust-1',
|
||||
helcimSubscriptionId: 'sub-1',
|
||||
billingCadence: 'monthly'
|
||||
}
|
||||
]))
|
||||
listHelcimCustomerTransactions.mockResolvedValue([
|
||||
{ id: 'tx-paid', status: 'paid', amount: 10, currency: 'CAD' },
|
||||
{ id: 'tx-other', status: 'other', amount: 0, currency: 'CAD' },
|
||||
{ id: 'tx-failed', status: 'failed', amount: 10, currency: 'CAD' }
|
||||
])
|
||||
upsertPaymentFromHelcim.mockResolvedValueOnce({ created: true, payment: { _id: 'p1' } })
|
||||
upsertPaymentFromHelcim.mockResolvedValueOnce({ created: false, payment: { _id: 'p2' } })
|
||||
|
||||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
path: '/api/internal/reconcile-payments',
|
||||
headers: { 'x-reconcile-token': RECONCILE_TOKEN }
|
||||
})
|
||||
|
||||
const result = await reconcileHandler(event)
|
||||
|
||||
// Verify the query shape: filter by helcimCustomerId existence + projection
|
||||
expect(Member.find).toHaveBeenCalledWith(
|
||||
{ helcimCustomerId: { $exists: true, $ne: null } },
|
||||
expect.objectContaining({
|
||||
helcimCustomerId: 1,
|
||||
helcimSubscriptionId: 1,
|
||||
billingCadence: 1
|
||||
})
|
||||
)
|
||||
|
||||
// upsert called for paid + failed (status='other' is skipped)
|
||||
expect(upsertPaymentFromHelcim).toHaveBeenCalledTimes(2)
|
||||
|
||||
expect(result).toMatchObject({
|
||||
membersScanned: 1,
|
||||
txExamined: 3,
|
||||
created: 1,
|
||||
existed: 1,
|
||||
skipped: 1,
|
||||
memberErrors: 0,
|
||||
apply: true
|
||||
})
|
||||
})
|
||||
|
||||
it('does NOT pass sendConfirmation: true (no duplicate confirmation emails)', async () => {
|
||||
Member.find.mockReturnValue(leanResolver([
|
||||
{ _id: 'm1', helcimCustomerId: 'cust-1' }
|
||||
]))
|
||||
listHelcimCustomerTransactions.mockResolvedValue([
|
||||
{ id: 'tx-paid', status: 'paid', amount: 10, currency: 'CAD' }
|
||||
])
|
||||
upsertPaymentFromHelcim.mockResolvedValue({ created: true, payment: { _id: 'p1' } })
|
||||
|
||||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
path: '/api/internal/reconcile-payments',
|
||||
headers: { 'x-reconcile-token': RECONCILE_TOKEN }
|
||||
})
|
||||
await reconcileHandler(event)
|
||||
|
||||
// The cron must not pass sendConfirmation. Either no opts or sendConfirmation falsy.
|
||||
const opts = upsertPaymentFromHelcim.mock.calls[0][2]
|
||||
if (opts) {
|
||||
expect(opts.sendConfirmation).toBeFalsy()
|
||||
}
|
||||
})
|
||||
|
||||
it('continues iterating when listHelcimCustomerTransactions throws for one member', async () => {
|
||||
Member.find.mockReturnValue(leanResolver([
|
||||
{ _id: 'm1', helcimCustomerId: 'cust-1' },
|
||||
{ _id: 'm2', helcimCustomerId: 'cust-2' },
|
||||
{ _id: 'm3', helcimCustomerId: 'cust-3' }
|
||||
]))
|
||||
// m1 succeeds first try, m2 fails all 3 retries, m3 succeeds first try.
|
||||
listHelcimCustomerTransactions
|
||||
.mockResolvedValueOnce([{ id: 'tx1', status: 'paid', amount: 5 }])
|
||||
.mockRejectedValueOnce(new Error('helcim 503'))
|
||||
.mockRejectedValueOnce(new Error('helcim 503'))
|
||||
.mockRejectedValueOnce(new Error('helcim 503'))
|
||||
.mockResolvedValueOnce([{ id: 'tx3', status: 'paid', amount: 7 }])
|
||||
upsertPaymentFromHelcim.mockResolvedValue({ created: true, payment: { _id: 'p' } })
|
||||
|
||||
vi.useFakeTimers()
|
||||
|
||||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
path: '/api/internal/reconcile-payments',
|
||||
headers: { 'x-reconcile-token': RECONCILE_TOKEN }
|
||||
})
|
||||
const promise = reconcileHandler(event)
|
||||
// Drain m2's exponential backoff (250 + 500 + 1000ms)
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
const result = await promise
|
||||
|
||||
expect(result.membersScanned).toBe(3)
|
||||
expect(result.memberErrors).toBe(1)
|
||||
expect(result.created).toBe(2) // m1 + m3 succeeded
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('retries transient Helcim errors with exponential backoff (3 attempts)', async () => {
|
||||
vi.useFakeTimers()
|
||||
Member.find.mockReturnValue(leanResolver([
|
||||
{ _id: 'm1', helcimCustomerId: 'cust-1' }
|
||||
]))
|
||||
listHelcimCustomerTransactions
|
||||
.mockRejectedValueOnce(new Error('boom 1'))
|
||||
.mockRejectedValueOnce(new Error('boom 2'))
|
||||
.mockResolvedValueOnce([{ id: 'tx-paid', status: 'paid', amount: 9 }])
|
||||
upsertPaymentFromHelcim.mockResolvedValue({ created: true, payment: { _id: 'p1' } })
|
||||
|
||||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
path: '/api/internal/reconcile-payments',
|
||||
headers: { 'x-reconcile-token': RECONCILE_TOKEN }
|
||||
})
|
||||
const promise = reconcileHandler(event)
|
||||
// Advance through the 250ms + 500ms backoff windows
|
||||
await vi.advanceTimersByTimeAsync(250)
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
const result = await promise
|
||||
|
||||
expect(listHelcimCustomerTransactions).toHaveBeenCalledTimes(3)
|
||||
expect(result.memberErrors).toBe(0)
|
||||
expect(result.created).toBe(1)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('counts memberErrors when all 3 retry attempts fail', async () => {
|
||||
vi.useFakeTimers()
|
||||
Member.find.mockReturnValue(leanResolver([
|
||||
{ _id: 'm1', helcimCustomerId: 'cust-1' }
|
||||
]))
|
||||
listHelcimCustomerTransactions.mockRejectedValue(new Error('persistent 503'))
|
||||
|
||||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
path: '/api/internal/reconcile-payments',
|
||||
headers: { 'x-reconcile-token': RECONCILE_TOKEN }
|
||||
})
|
||||
const promise = reconcileHandler(event)
|
||||
await vi.advanceTimersByTimeAsync(250)
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
const result = await promise
|
||||
|
||||
expect(listHelcimCustomerTransactions).toHaveBeenCalledTimes(3)
|
||||
expect(result.memberErrors).toBe(1)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('honors ?apply=false dry-run mode (Payment.findOne, no upsert)', async () => {
|
||||
Member.find.mockReturnValue(leanResolver([
|
||||
{ _id: 'm1', helcimCustomerId: 'cust-1' }
|
||||
]))
|
||||
listHelcimCustomerTransactions.mockResolvedValue([
|
||||
{ id: 'tx-existing', status: 'paid', amount: 10 },
|
||||
{ id: 'tx-new', status: 'paid', amount: 12 }
|
||||
])
|
||||
Payment.findOne
|
||||
.mockResolvedValueOnce({ _id: 'p-existing' })
|
||||
.mockResolvedValueOnce(null)
|
||||
|
||||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
path: '/api/internal/reconcile-payments?apply=false',
|
||||
headers: { 'x-reconcile-token': RECONCILE_TOKEN }
|
||||
})
|
||||
const result = await reconcileHandler(event)
|
||||
|
||||
expect(upsertPaymentFromHelcim).not.toHaveBeenCalled()
|
||||
expect(Payment.findOne).toHaveBeenCalledTimes(2)
|
||||
expect(result).toMatchObject({
|
||||
apply: false,
|
||||
created: 1, // would-create
|
||||
existed: 1
|
||||
})
|
||||
})
|
||||
})
|
||||
98
tests/server/api/series-tickets-purchase.test.js
Normal file
98
tests/server/api/series-tickets-purchase.test.js
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
const seriesDir = resolve(import.meta.dirname, '../../../server/api/series/[id]')
|
||||
|
||||
describe('series tickets/purchase.post.js — guest account upsert (Fix #8)', () => {
|
||||
const source = readFileSync(resolve(seriesDir, 'tickets/purchase.post.js'), 'utf-8')
|
||||
|
||||
it('uses validateBody with seriesTicketPurchaseSchema', () => {
|
||||
expect(source).toContain('validateBody(event, seriesTicketPurchaseSchema)')
|
||||
})
|
||||
|
||||
it('Case 1 (free) + Case 2 (paid): upserts a guest Member when unauthenticated buyer provides name+email', () => {
|
||||
// Mirror event endpoint upsert pattern; ALWAYS-CREATE-GUEST (no opt-in
|
||||
// checkbox), so guard is `if (!member)` rather than `if (!member && body.createAccount)`.
|
||||
expect(source).toContain('findOneAndUpdate')
|
||||
expect(source).toContain('$setOnInsert')
|
||||
expect(source).toContain('status: "guest"')
|
||||
expect(source).toContain('upsert: true')
|
||||
expect(source).toContain('circle: "community"')
|
||||
expect(source).toContain('contributionAmount: 0')
|
||||
// ALWAYS-CREATE — must NOT gate on a createAccount flag
|
||||
expect(source).not.toContain('body.createAccount')
|
||||
})
|
||||
|
||||
it('Case 3 (idempotency): upsert pattern handles concurrent same-email registrations atomically', () => {
|
||||
// findOneAndUpdate with $setOnInsert + upsert:true is the idempotent pattern;
|
||||
// email has a unique index. No duplicate Member doc created on retry.
|
||||
expect(source).toMatch(/findOneAndUpdate\(\s*\{\s*email:/)
|
||||
expect(source).toContain('upsert: true')
|
||||
expect(source).toContain('new: true')
|
||||
expect(source).toContain('setDefaultsOnInsert: true')
|
||||
})
|
||||
|
||||
it('Case 4 (existing real member): does not auto-login real members entered via public form', () => {
|
||||
// Auto-login only for newly-created accounts and existing guests.
|
||||
// Real members (active/pending_payment) get requiresSignIn: true instead.
|
||||
expect(source).toContain('accountCreated || member.status === "guest"')
|
||||
expect(source).toContain('requiresSignIn = true')
|
||||
})
|
||||
|
||||
it('Case 5 (authenticated guest): sets auth cookie on signedIn:true response', () => {
|
||||
// setAuthCookie fires for both new accounts and returning guests.
|
||||
expect(source).toContain('setAuthCookie(event, member)')
|
||||
expect(source).toContain('signedIn = true')
|
||||
})
|
||||
|
||||
it('Case 6 (missing fields): relies on schema validation to reject missing name/email', () => {
|
||||
// No new validation logic added — existing seriesTicketPurchaseSchema
|
||||
// already requires name+email; validateBody throws 400 if missing.
|
||||
expect(source).toContain('validateBody(event, seriesTicketPurchaseSchema)')
|
||||
})
|
||||
|
||||
it('includes accountCreated, signedIn, and requiresSignIn in response (parity with event endpoint)', () => {
|
||||
expect(source).toContain('accountCreated,')
|
||||
expect(source).toContain('signedIn,')
|
||||
expect(source).toContain('requiresSignIn,')
|
||||
})
|
||||
|
||||
it('still uses hasMemberAccess to gate member pricing (guest/suspended/cancelled treated as non-members)', () => {
|
||||
expect(source).toContain('hasMemberAccess(member)')
|
||||
})
|
||||
|
||||
it('preserves try/catch around requireAuth so unauthenticated callers fall through', () => {
|
||||
// Required for unauth guest-purchase flow to work at all.
|
||||
expect(source).toMatch(/try\s*\{[^}]*requireAuth\(event\)[^}]*\}\s*catch/s)
|
||||
})
|
||||
|
||||
it('does not block purchase when confirmation email fails', () => {
|
||||
const emailCallIndex = source.indexOf('await sendSeriesPassConfirmation')
|
||||
expect(emailCallIndex).toBeGreaterThan(-1)
|
||||
const afterEmail = source.slice(emailCallIndex)
|
||||
const catchBlock = afterEmail.match(/catch\s*\(\w+\)\s*\{[^}]*\}/s)
|
||||
expect(catchBlock).not.toBeNull()
|
||||
expect(catchBlock[0]).toContain('console.error')
|
||||
})
|
||||
})
|
||||
|
||||
describe('SeriesPassPurchase.vue — client auth refresh (Fix #8)', () => {
|
||||
const source = readFileSync(
|
||||
resolve(import.meta.dirname, '../../../app/components/SeriesPassPurchase.vue'),
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
it('refreshes client auth state via useAuth().checkMemberStatus() when server reports signedIn', () => {
|
||||
expect(source).toContain('useAuth().checkMemberStatus()')
|
||||
expect(source).toMatch(/purchaseResponse\?\.signedIn/)
|
||||
})
|
||||
|
||||
it('shows a one-line guest-account hint under the form (no checkbox)', () => {
|
||||
// Per ALWAYS-CREATE-GUEST decision: hint only, no UI control.
|
||||
expect(source).toMatch(/free guest account/i)
|
||||
// Make sure no checkbox was added by mistake.
|
||||
expect(source).not.toMatch(/createAccount/)
|
||||
expect(source).not.toMatch(/<input[^>]*type="checkbox"/i)
|
||||
})
|
||||
})
|
||||
|
|
@ -12,7 +12,6 @@ import {
|
|||
waitlistDeleteSchema,
|
||||
cancelRegistrationSchema,
|
||||
checkRegistrationSchema,
|
||||
eventPaymentSchema,
|
||||
updateContributionSchema,
|
||||
seriesTicketPurchaseSchema,
|
||||
seriesTicketEligibilitySchema,
|
||||
|
|
@ -312,25 +311,6 @@ describe('waitlistSchema', () => {
|
|||
})
|
||||
})
|
||||
|
||||
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', () => {
|
||||
|
|
@ -557,7 +537,6 @@ describe('validateBody migration coverage', () => {
|
|||
'events/[id]/waitlist.delete.js',
|
||||
'events/[id]/cancel-registration.post.js',
|
||||
'events/[id]/check-registration.post.js',
|
||||
'events/[id]/payment.post.js',
|
||||
'members/update-contribution.post.js',
|
||||
'series/[id]/tickets/purchase.post.js',
|
||||
'series/[id]/tickets/check-eligibility.post.js',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`runtimeConfig.public > matches public runtime config snapshot 1`] = `
|
||||
[
|
||||
"appUrl",
|
||||
"cloudinaryCloudName",
|
||||
"comingSoon",
|
||||
"helcimAccountId",
|
||||
"helcimPortalUrl",
|
||||
]
|
||||
`;
|
||||
36
tests/server/config/runtime-config-public.test.js
Normal file
36
tests/server/config/runtime-config-public.test.js
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { describe, it, expect, beforeAll } from 'vitest'
|
||||
|
||||
// nuxt.config.ts uses the auto-imported defineNuxtConfig() global. Stub it to
|
||||
// the identity function so we can dynamically import the config in node and
|
||||
// assert against the actual runtimeConfig object the build will use.
|
||||
let nuxtConfig
|
||||
|
||||
beforeAll(async () => {
|
||||
globalThis.defineNuxtConfig = (config) => config
|
||||
const mod = await import('../../../nuxt.config.ts')
|
||||
nuxtConfig = mod.default
|
||||
})
|
||||
|
||||
describe('runtimeConfig.public', () => {
|
||||
it('does not expose helcimToken in runtimeConfig.public', () => {
|
||||
expect('helcimToken' in nuxtConfig.runtimeConfig.public).toBe(false)
|
||||
})
|
||||
|
||||
it('does not expose any *ApiToken or *Secret keys in runtimeConfig.public', () => {
|
||||
// Keys that are intentionally public despite matching the pattern.
|
||||
const allowlist = new Set(['helcimAccountId', 'cloudinaryCloudName'])
|
||||
const sensitivePattern = /token|secret|key$/i
|
||||
|
||||
const violations = Object.keys(nuxtConfig.runtimeConfig.public).filter(
|
||||
(key) => sensitivePattern.test(key) && !allowlist.has(key)
|
||||
)
|
||||
|
||||
expect(violations).toEqual([])
|
||||
})
|
||||
|
||||
it('matches public runtime config snapshot', () => {
|
||||
// Snapshot only the sorted key list, not values (values come from env).
|
||||
const sortedKeys = Object.keys(nuxtConfig.runtimeConfig.public).sort()
|
||||
expect(sortedKeys).toMatchSnapshot()
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue