SnapAPI/src/routes/__tests__/billing.test.ts
Hoid 9575d312fe
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 10m13s
fix: cancelled tier, remove key logging, add billing rate limits
- Add 'cancelled' tier (0 req/month) for downgraded subscriptions
- Remove full API key from recovery endpoint logs (security)
- Add IP-based rate limiting (10/15min) to billing endpoints
- Bump version to 0.7.0
- 4 new tests (338 total)
2026-03-04 09:06:16 +01:00

378 lines
16 KiB
TypeScript

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