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