ghostguild-org/tests/server/api/helcim-customer.test.js
Jennie Robinson Faber da5e7efcb7 fix(launch-flow): auto-link /join signups to existing PreRegistration
When a /join submitter's email matches a pending/selected/invited
PreRegistration, mark the pre-reg as accepted and link memberId to the
new Member. Prevents the same person from appearing as both an active
member and an unaccepted pre-registrant. Silent — no email, no UI.

Adds the PreRegistration mock to helcim-customer and free-signup-flow
test suites, since both invoke the customer handler at runtime.
2026-04-30 14:43:02 +01:00

388 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/models/preRegistration.js', () => ({
default: { findOne: vi.fn().mockResolvedValue(null), 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()
})
})
})