import { describe, it, expect, vi, beforeEach } from 'vitest' import request from 'supertest' import express from 'express' // Hoist mock functions so they're available in vi.mock factories const { mockBillingPortalCreate, mockCheckoutSessionsCreate, mockCheckoutSessionsRetrieve, mockSubscriptionsRetrieve, mockWebhooksConstructEvent, mockProductsSearch, mockPricesList, mockPricesCreate, mockPricesRetrieve, mockProductsCreate, mockStripeInstance, } = vi.hoisted(() => { const mockBillingPortalCreate = vi.fn() const mockCheckoutSessionsCreate = vi.fn() const mockCheckoutSessionsRetrieve = vi.fn() const mockSubscriptionsRetrieve = vi.fn() const mockWebhooksConstructEvent = vi.fn() const mockProductsSearch = vi.fn() const mockPricesList = vi.fn() const mockPricesCreate = vi.fn() const mockPricesRetrieve = vi.fn() const mockProductsCreate = vi.fn() const mockStripeInstance = { billingPortal: { sessions: { create: mockBillingPortalCreate } }, checkout: { sessions: { create: mockCheckoutSessionsCreate, retrieve: mockCheckoutSessionsRetrieve } }, subscriptions: { retrieve: mockSubscriptionsRetrieve }, webhooks: { constructEvent: mockWebhooksConstructEvent }, products: { search: mockProductsSearch, create: mockProductsCreate }, prices: { list: mockPricesList, create: mockPricesCreate, retrieve: mockPricesRetrieve }, } return { mockBillingPortalCreate, mockCheckoutSessionsCreate, mockCheckoutSessionsRetrieve, mockSubscriptionsRetrieve, mockWebhooksConstructEvent, mockProductsSearch, mockPricesList, mockPricesCreate, mockPricesRetrieve, mockProductsCreate, mockStripeInstance, } }) vi.mock('../../services/logger.js', () => ({ default: { info: vi.fn(), error: vi.fn() } })) vi.mock('../../services/keys.js', () => ({ getCustomerIdByEmail: vi.fn(), getKeyByEmail: vi.fn(), createPaidKey: vi.fn(), downgradeByCustomer: vi.fn(), updateEmailByCustomer: vi.fn(), })) vi.mock('stripe', () => ({ default: function Stripe() { return mockStripeInstance }, })) // Set env vars BEFORE import (vi.stubEnv may not work for module-level reads) process.env.STRIPE_SECRET_KEY = 'sk_test_123456789' process.env.STRIPE_WEBHOOK_SECRET = 'whsec_test_secret' process.env.BASE_URL = 'https://test.snapapi.eu' // Setup default mocks for initPrices (runs on import) — prices not found, so create them mockProductsSearch.mockResolvedValue({ data: [{ id: 'prod_snap_test' }] }) mockPricesList.mockImplementation(async () => ({ data: [] })) mockPricesCreate.mockImplementation(async ({ unit_amount }: any) => ({ id: `price_${unit_amount}` })) import { billingRouter } from '../billing.js' import { getCustomerIdByEmail, getKeyByEmail, createPaidKey, downgradeByCustomer, updateEmailByCustomer } from '../../services/keys.js' const app = express() app.use('/v1/billing/webhook', express.raw({ type: '*/*' })) app.use(express.json()) app.use('/v1/billing', billingRouter) describe('POST /v1/billing/portal', () => { beforeEach(() => { vi.clearAllMocks() }) it('should return portal URL when email has stripe customer ID', async () => { vi.mocked(getCustomerIdByEmail).mockResolvedValue('cus_123456') mockBillingPortalCreate.mockResolvedValue({ url: 'https://billing.stripe.com/p/session_123456' }) const response = await request(app).post('/v1/billing/portal').send({ email: 'user@example.com' }) expect(response.status).toBe(200) expect(response.body).toEqual({ url: 'https://billing.stripe.com/p/session_123456' }) expect(getCustomerIdByEmail).toHaveBeenCalledWith('user@example.com') expect(mockBillingPortalCreate).toHaveBeenCalledWith( expect.objectContaining({ customer: 'cus_123456' }) ) }) it('should return 404 when email has no stripe customer ID', async () => { vi.mocked(getCustomerIdByEmail).mockResolvedValue(undefined) const response = await request(app).post('/v1/billing/portal').send({ email: 'nonexistent@example.com' }) expect(response.status).toBe(404) expect(response.body.error).toContain('No subscription found') }) it('should return 400 when email is missing', async () => { const response = await request(app).post('/v1/billing/portal').send({}) expect(response.status).toBe(400) expect(response.body).toEqual({ error: 'Email address is required' }) }) it('should return 400 when email is empty string', async () => { const response = await request(app).post('/v1/billing/portal').send({ email: '' }) expect(response.status).toBe(400) expect(response.body).toEqual({ error: 'Email address is required' }) }) }) describe('GET /v1/billing/recover', () => { beforeEach(() => { vi.clearAllMocks() }) it('should return success message and masked key when email exists', async () => { vi.mocked(getKeyByEmail).mockResolvedValue({ key: 'snap_abcd1234efgh5678ijkl9012', tier: 'pro', email: 'user@example.com', createdAt: '2024-01-01T00:00:00Z', stripeCustomerId: 'cus_123456' }) const response = await request(app).get('/v1/billing/recover').query({ email: 'user@example.com' }) expect(response.status).toBe(200) expect(response.body.maskedKey).toBe('snap_abcd...9012') }) it('should return success message when email does not exist (no info leak)', async () => { vi.mocked(getKeyByEmail).mockResolvedValue(undefined) const response = await request(app).get('/v1/billing/recover').query({ email: 'nonexistent@example.com' }) expect(response.status).toBe(200) expect(response.body.message).toContain('If an account exists') expect(response.body.maskedKey).toBeUndefined() }) it('should return 400 when email is missing', async () => { const response = await request(app).get('/v1/billing/recover') expect(response.status).toBe(400) }) it('should return 400 when email is empty string', async () => { const response = await request(app).get('/v1/billing/recover').query({ email: '' }) expect(response.status).toBe(400) }) it('should properly mask API keys with correct format', async () => { vi.mocked(getKeyByEmail).mockResolvedValue({ key: 'snap_1234567890abcdef', tier: 'starter', email: 'user@example.com', createdAt: '2024-01-01T00:00:00Z' }) const response = await request(app).get('/v1/billing/recover').query({ email: 'user@example.com' }) expect(response.body.maskedKey).toBe('snap_1234...cdef') }) }) describe('POST /v1/billing/checkout', () => { beforeEach(() => { vi.clearAllMocks() mockProductsSearch.mockResolvedValue({ data: [{ id: 'prod_snap_test' }] }) mockPricesList.mockResolvedValue({ data: [{ id: 'price_test', unit_amount: 900 }] }) }) it('should return 400 for missing plan', async () => { const response = await request(app).post('/v1/billing/checkout').send({}) expect(response.status).toBe(400) expect(response.body.error).toContain('Invalid plan') }) it('should return 400 for invalid plan name', async () => { const response = await request(app).post('/v1/billing/checkout').send({ plan: 'enterprise' }) expect(response.status).toBe(400) expect(response.body.error).toContain('Invalid plan') }) it('should return checkout URL for valid plan (starter)', async () => { mockCheckoutSessionsCreate.mockResolvedValue({ url: 'https://checkout.stripe.com/session_abc' }) const response = await request(app).post('/v1/billing/checkout').send({ plan: 'starter' }) expect(response.status).toBe(200) expect(response.body).toEqual({ url: 'https://checkout.stripe.com/session_abc' }) expect(mockCheckoutSessionsCreate).toHaveBeenCalledWith(expect.objectContaining({ mode: 'subscription', metadata: { plan: 'starter', service: 'snapapi' }, })) }) it('should return checkout URL for pro plan', async () => { mockCheckoutSessionsCreate.mockResolvedValue({ url: 'https://checkout.stripe.com/pro' }) const response = await request(app).post('/v1/billing/checkout').send({ plan: 'pro' }) expect(response.status).toBe(200) expect(response.body.url).toBe('https://checkout.stripe.com/pro') }) it('should return checkout URL for business plan', async () => { mockCheckoutSessionsCreate.mockResolvedValue({ url: 'https://checkout.stripe.com/biz' }) const response = await request(app).post('/v1/billing/checkout').send({ plan: 'business' }) expect(response.status).toBe(200) expect(response.body.url).toBe('https://checkout.stripe.com/biz') }) it('should return 500 on Stripe API error', async () => { mockCheckoutSessionsCreate.mockRejectedValue(new Error('Stripe is down')) const response = await request(app).post('/v1/billing/checkout').send({ plan: 'starter' }) expect(response.status).toBe(500) expect(response.body.error).toContain('Failed to create checkout session') }) }) describe('GET /v1/billing/success', () => { beforeEach(() => { vi.clearAllMocks() }) it('should return 400 for missing session_id', async () => { const response = await request(app).get('/v1/billing/success') expect(response.status).toBe(400) expect(response.text).toBe('Missing session_id') }) it('should return HTML with API key for valid session', async () => { mockCheckoutSessionsRetrieve.mockResolvedValue({ id: 'cs_test_unique_success_1', customer_details: { email: 'buyer@example.com' }, metadata: { plan: 'pro' }, customer: 'cus_buyer_1', }) vi.mocked(createPaidKey).mockResolvedValue({ key: 'snap_newkey123456' } as any) const response = await request(app).get('/v1/billing/success').query({ session_id: 'cs_test_unique_success_1' }) expect(response.status).toBe(200) expect(response.headers['content-type']).toMatch(/html/) expect(response.text).toContain('snap_newkey123456') expect(response.text).toContain('Welcome to SnapAPI') expect(response.text).toContain('Pro Plan') expect(createPaidKey).toHaveBeenCalledWith('buyer@example.com', 'pro', 'cus_buyer_1') }) it('should handle duplicate session_id (dedup via provisionedSessions)', async () => { const sessionId = 'cs_test_dedup_unique_2' mockCheckoutSessionsRetrieve.mockResolvedValue({ id: sessionId, customer_details: { email: 'dedup@example.com' }, metadata: { plan: 'starter' }, customer: 'cus_dedup', }) vi.mocked(createPaidKey).mockResolvedValue({ key: 'snap_first_key' } as any) await request(app).get('/v1/billing/success').query({ session_id: sessionId }) vi.mocked(createPaidKey).mockClear() const response = await request(app).get('/v1/billing/success').query({ session_id: sessionId }) expect(response.status).toBe(200) expect(response.text).toContain('already provisioned') expect(createPaidKey).not.toHaveBeenCalled() }) it('should return 500 on Stripe API error', async () => { mockCheckoutSessionsRetrieve.mockRejectedValue(new Error('Stripe error')) const response = await request(app).get('/v1/billing/success').query({ session_id: 'cs_test_error_3' }) expect(response.status).toBe(500) expect(response.text).toContain('Something went wrong') }) }) describe('GET /v1/billing/recover - security', () => { beforeEach(() => { vi.clearAllMocks() }) it('should return masked key, not the full key', async () => { vi.mocked(getKeyByEmail).mockResolvedValue({ key: 'snap_abcdef1234567890abcdef1234567890abcdef12345678', tier: 'pro', email: 'user@example.com', createdAt: '2024-01-01T00:00:00Z', }) const response = await request(app).get('/v1/billing/recover').query({ email: 'user@example.com' }) expect(response.status).toBe(200) expect(response.body.maskedKey).toBeDefined() expect(response.body.maskedKey).toContain('...') // Must NOT contain the full key expect(response.body.key).toBeUndefined() }) }) describe('Billing rate limiting', () => { it('should return rate limit headers on billing endpoints', async () => { vi.mocked(getKeyByEmail).mockResolvedValue(undefined) const response = await request(app).get('/v1/billing/recover').query({ email: 'test@example.com' }) expect(response.headers['ratelimit-limit']).toBeDefined() }) }) describe('POST /v1/billing/webhook', () => { beforeEach(() => { vi.clearAllMocks() }) it('should return 400 for missing stripe-signature header', async () => { const response = await request(app).post('/v1/billing/webhook').send(Buffer.from('{}')) expect(response.status).toBe(400) expect(response.text).toBe('Missing signature') }) it('should handle checkout.session.completed — provisions key', async () => { const sessionId = 'cs_webhook_unique_4' mockWebhooksConstructEvent.mockReturnValue({ id: 'evt_1', type: 'checkout.session.completed', data: { object: { id: sessionId, customer_details: { email: 'webhook@example.com' }, metadata: { plan: 'pro' }, customer: 'cus_wh_1', subscription: 'sub_123', }} }) mockSubscriptionsRetrieve.mockResolvedValue({ items: { data: [{ price: { product: 'prod_snap_test' } }] } }) vi.mocked(createPaidKey).mockResolvedValue({ key: 'snap_wh_key' } as any) const response = await request(app) .post('/v1/billing/webhook').set('stripe-signature', 'sig_valid').send(Buffer.from('{}')) expect(response.status).toBe(200) expect(response.body.received).toBe(true) expect(createPaidKey).toHaveBeenCalledWith('webhook@example.com', 'pro', 'cus_wh_1') }) it('should handle customer.subscription.updated with canceled status — downgrades', async () => { mockWebhooksConstructEvent.mockReturnValue({ id: 'evt_2', type: 'customer.subscription.updated', data: { object: { customer: 'cus_cancel_1', status: 'canceled', items: { data: [{ price: { product: 'prod_snap_test' } }] }, }} }) const response = await request(app) .post('/v1/billing/webhook').set('stripe-signature', 'sig_valid').send(Buffer.from('{}')) expect(response.status).toBe(200) expect(downgradeByCustomer).toHaveBeenCalledWith('cus_cancel_1') }) it('should handle customer.subscription.deleted — downgrades', async () => { mockWebhooksConstructEvent.mockReturnValue({ id: 'evt_3', type: 'customer.subscription.deleted', data: { object: { customer: 'cus_delete_1', items: { data: [{ price: { product: 'prod_snap_test' } }] }, }} }) const response = await request(app) .post('/v1/billing/webhook').set('stripe-signature', 'sig_valid').send(Buffer.from('{}')) expect(response.status).toBe(200) expect(downgradeByCustomer).toHaveBeenCalledWith('cus_delete_1') }) it('should handle customer.updated — updates email', async () => { mockWebhooksConstructEvent.mockReturnValue({ id: 'evt_4', type: 'customer.updated', data: { object: { id: 'cus_email_update', email: 'newemail@example.com' } } }) const response = await request(app) .post('/v1/billing/webhook').set('stripe-signature', 'sig_valid').send(Buffer.from('{}')) expect(response.status).toBe(200) expect(updateEmailByCustomer).toHaveBeenCalledWith('cus_email_update', 'newemail@example.com') }) it('should ignore non-SnapAPI events (different product ID)', async () => { mockWebhooksConstructEvent.mockReturnValue({ id: 'evt_5', type: 'customer.subscription.updated', data: { object: { customer: 'cus_other', status: 'canceled', items: { data: [{ price: { product: 'prod_OTHER_not_snapapi' } }] }, }} }) const response = await request(app) .post('/v1/billing/webhook').set('stripe-signature', 'sig_valid').send(Buffer.from('{}')) expect(response.status).toBe(200) expect(response.body).toEqual({ received: true, ignored: true }) expect(downgradeByCustomer).not.toHaveBeenCalled() }) it('should return 400 for invalid signature', async () => { mockWebhooksConstructEvent.mockImplementation(() => { throw new Error('Invalid signature') }) const response = await request(app) .post('/v1/billing/webhook').set('stripe-signature', 'sig_invalid').send(Buffer.from('{}')) expect(response.status).toBe(400) expect(response.text).toContain('Webhook error') }) })