After commit 90acc35 issued the cookie for $0 signups too, the "payment"
framing was wrong — there's no payment in a $0 signup. The cookie is
about bridging the gap between signup-form submit and email verify, not
about payment specifically.
Changes:
- setPaymentBridgeCookie → setSignupBridgeCookie
- getPaymentBridgeMember → getSignupBridgeMember
- Cookie wire name payment-bridge → signup-bridge
- JWT scope payment_bridge → signup_bridge
Touches both /api/helcim/subscription (signup activation) and
/api/helcim/initialize-payment (paid Helcim checkout) which both consume
the cookie. In-flight signup sessions started before this lands will
need to re-submit the form (cookie name mismatch); cutover hasn't
happened yet, so the only impact is local dev sessions.
388 lines
13 KiB
JavaScript
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, setSignupBridgeCookie } 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(),
|
|
setSignupBridgeCookie: 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(setSignupBridgeCookie).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 signup-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(setSignupBridgeCookie).toHaveBeenCalled()
|
|
expect(sendMagicLink).toHaveBeenCalledWith(
|
|
'paid@example.com',
|
|
expect.objectContaining({ subject: 'Verify your Ghost Guild signup' })
|
|
)
|
|
// still no full session cookie
|
|
expect(setAuthCookie).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
})
|