diff --git a/tests/server/api/free-signup-flow.test.js b/tests/server/api/free-signup-flow.test.js new file mode 100644 index 0000000..c7789ad --- /dev/null +++ b/tests/server/api/free-signup-flow.test.js @@ -0,0 +1,156 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +import Member from '../../../server/models/member.js' +import { createHelcimCustomer } from '../../../server/utils/helcim.js' +import customerHandler from '../../../server/api/helcim/customer.post.js' +import subscriptionHandler from '../../../server/api/helcim/subscription.post.js' +import { resetRateLimit } from '../../../server/utils/rateLimit.js' +import { sendWelcomeEmail } from '../../../server/utils/resend.js' +import { createMockEvent } from '../helpers/createMockEvent.js' + +// IMPORTANT: do NOT mock server/utils/auth.js. This test exists to verify the +// real bridge-cookie hand-off between the two handlers. Mocking auth would +// hide the exact regression we're guarding against (setPaymentBridgeCookie +// being skipped for $0 signups while subscription.post.js still requires +// either a bridge cookie or a verified session). + +vi.mock('../../../server/models/member.js', () => ({ + default: { + findOne: vi.fn(), + create: vi.fn(), + findByIdAndUpdate: vi.fn(), + findById: vi.fn(), + findOneAndUpdate: vi.fn() + } +})) +vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() })) +vi.mock('../../../server/utils/helcim.js', () => ({ + createHelcimCustomer: vi.fn(), + createHelcimSubscription: vi.fn(), + generateIdempotencyKey: vi.fn().mockReturnValue('idem-1'), + listHelcimCustomerTransactions: vi.fn().mockResolvedValue([]) +})) +vi.mock('../../../server/utils/magicLink.js', () => ({ + sendMagicLink: vi.fn().mockResolvedValue(undefined) +})) +vi.mock('../../../server/utils/resend.js', () => ({ + sendWelcomeEmail: vi.fn().mockResolvedValue({ success: true }) +})) +vi.mock('../../../server/utils/slack.ts', () => ({ + getSlackService: vi.fn().mockReturnValue(null) +})) +vi.mock('../../../server/utils/payments.js', () => ({ + upsertPaymentFromHelcim: vi.fn().mockResolvedValue({ created: true }) +})) + +vi.stubGlobal('helcimCustomerSchema', {}) +vi.stubGlobal('helcimSubscriptionSchema', {}) + +const ALLOWED_ORIGIN = 'https://ghostguild.test' +const MEMBER_ID = '69f231152939bf109ac79d83' + +function extractBridgeCookie(event) { + const setCookie = event.node.res.getHeader('set-cookie') + const cookies = Array.isArray(setCookie) ? setCookie : [setCookie].filter(Boolean) + const match = cookies.find(c => typeof c === 'string' && c.startsWith('payment-bridge=')) + if (!match) return null + return match.match(/payment-bridge=([^;]+)/)[1] +} + +describe('signup → subscription bridge-cookie hand-off', () => { + beforeEach(() => { + vi.clearAllMocks() + resetRateLimit() + process.env.BASE_URL = ALLOWED_ORIGIN + + createHelcimCustomer.mockResolvedValue({ id: 999, customerCode: 'CST999' }) + Member.findOne.mockResolvedValue(null) + Member.create.mockResolvedValue({ + _id: MEMBER_ID, + email: 'free@example.com', + name: 'Free User', + circle: 'community', + contributionAmount: 0, + status: 'pending_payment' + }) + }) + + it('$0 signup: customer endpoint issues a bridge cookie that subscription endpoint accepts', async () => { + // --- Step 1: POST /api/helcim/customer (free tier) --- + const customerEvent = createMockEvent({ + method: 'POST', + path: '/api/helcim/customer', + headers: { origin: ALLOWED_ORIGIN }, + body: { + name: 'Free User', + email: 'free@example.com', + circle: 'community', + contributionAmount: 0, + agreedToGuidelines: true, + billingAddress: { country: 'CA' } + } + }) + + const result1 = await customerHandler(customerEvent) + expect(result1.success).toBe(true) + expect(result1.member.status).toBe('pending_payment') + + // The regression: prior code skipped setPaymentBridgeCookie when + // contributionAmount === 0, leaving the user unable to complete + // subscription activation in the same flow. + const bridgeToken = extractBridgeCookie(customerEvent) + expect(bridgeToken, 'payment-bridge cookie missing on $0 signup').toBeTruthy() + + // --- Step 2: POST /api/helcim/subscription (free tier) --- + Member.findOneAndUpdate.mockResolvedValue({ _id: MEMBER_ID, status: 'pending_payment' }) + Member.findById.mockResolvedValue({ + _id: MEMBER_ID, + email: 'free@example.com', + name: 'Free User', + circle: 'community', + contributionAmount: 0, + status: 'active' + }) + + const subscriptionEvent = createMockEvent({ + method: 'POST', + path: '/api/helcim/subscription', + headers: { origin: ALLOWED_ORIGIN }, + cookies: { 'payment-bridge': bridgeToken }, + body: { + customerId: 999, + customerCode: 'CST999', + contributionAmount: 0, + cadence: 'monthly', + cardToken: null + } + }) + + const result2 = await subscriptionHandler(subscriptionEvent) + expect(result2.success).toBe(true) + expect(result2.member.status).toBe('active') + expect(sendWelcomeEmail).toHaveBeenCalledTimes(1) + }) + + it('$0 signup with no bridge cookie carried forward → subscription returns 401', async () => { + // Sanity check: confirms the auth gate still rejects fresh, unauthenticated + // calls to /api/helcim/subscription. If this ever stops failing, the + // bridge cookie has stopped being load-bearing and the gate is open. + const subscriptionEvent = createMockEvent({ + method: 'POST', + path: '/api/helcim/subscription', + headers: { origin: ALLOWED_ORIGIN }, + body: { + customerId: 999, + customerCode: 'CST999', + contributionAmount: 0, + cadence: 'monthly', + cardToken: null + } + }) + + await expect(subscriptionHandler(subscriptionEvent)).rejects.toMatchObject({ + statusCode: 401 + }) + }) +})