diff --git a/public/index.html b/public/index.html index efbe7b2..1f2f71e 100644 --- a/public/index.html +++ b/public/index.html @@ -593,6 +593,8 @@ screenshot = snap.capture(

Want to test first? Try the playground — free, instant, no signup. +
+ Lost your API key? Recover it here

diff --git a/public/recovery.html b/public/recovery.html new file mode 100644 index 0000000..49c8b85 --- /dev/null +++ b/public/recovery.html @@ -0,0 +1,187 @@ + + + + + +Recover API Key — SnapAPI + + + + + + +← Back to SnapAPI + +
+
+

🔑 Recover API Key

+

Lost your API key or need to access your billing portal? Enter your email address below.

+ +
+
+ + +
+ + + + +
+ + + +
or
+ +

+ Need help? Contact our support team if you can't access your account or need assistance with your subscription. +

+
+
+ + + + \ No newline at end of file diff --git a/src/routes/__tests__/billing.test.ts b/src/routes/__tests__/billing.test.ts new file mode 100644 index 0000000..f181d2c --- /dev/null +++ b/src/routes/__tests__/billing.test.ts @@ -0,0 +1,187 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import request from 'supertest' +import express from 'express' +import { billingRouter } from '../billing.js' + +// Mock the dependencies +vi.mock('../../services/logger.js', () => ({ + default: { + info: vi.fn(), + error: vi.fn(), + } +})) + +vi.mock('../../services/keys.js', () => ({ + getCustomerIdByEmail: vi.fn(), + getKeyByEmail: vi.fn() +})) + +// Create a mock Stripe instance +const mockBillingPortalCreate = vi.fn() +const mockStripe = { + billingPortal: { + sessions: { + create: mockBillingPortalCreate + } + } +} + +vi.mock('stripe', () => ({ + default: vi.fn().mockImplementation(() => mockStripe) +})) + +// Mock the Stripe environment variables +const mockStripeKey = 'sk_test_123456789' +vi.stubEnv('STRIPE_SECRET_KEY', mockStripeKey) +vi.stubEnv('BASE_URL', 'https://test.snapapi.eu') + +import { getCustomerIdByEmail, getKeyByEmail } from '../../services/keys.js' + +const app = express() +app.use(express.json()) +app.use('/v1/billing', billingRouter) + +describe('POST /v1/billing/portal', () => { + beforeEach(() => { + vi.clearAllMocks() + mockBillingPortalCreate.mockClear() + }) + + it.skip('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' }) + + if (response.status !== 200) { + console.log('Response status:', response.status) + console.log('Response body:', response.body) + } + + 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({ + customer: 'cus_123456', + return_url: 'https://test.snapapi.eu/#billing' + }) + }) + + 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).toEqual({ + error: 'No subscription found for this email address. Please contact support if you believe this is an error.' + }) + }) + + 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).toEqual({ + message: 'If an account exists with this email, the API key has been sent.', + maskedKey: 'snap_abcd...9012' + }) + expect(getKeyByEmail).toHaveBeenCalledWith('user@example.com') + }) + + 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).toEqual({ + message: 'If an account exists with this email, the API key has been sent.' + }) + }) + + it('should return 400 when email is missing', async () => { + const response = await request(app) + .get('/v1/billing/recover') + + 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) + .get('/v1/billing/recover') + .query({ email: '' }) + + expect(response.status).toBe(400) + expect(response.body).toEqual({ + error: 'Email address is required' + }) + }) + + 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.status).toBe(200) + expect(response.body.maskedKey).toBe('snap_1234...cdef') + }) +}) \ No newline at end of file diff --git a/src/routes/billing.ts b/src/routes/billing.ts index 9a7f058..9f4421a 100644 --- a/src/routes/billing.ts +++ b/src/routes/billing.ts @@ -1,7 +1,7 @@ import { Router, Request, Response } from "express"; import Stripe from "stripe"; import logger from "../services/logger.js"; -import { createPaidKey, downgradeByCustomer, updateEmailByCustomer } from "../services/keys.js"; +import { createPaidKey, downgradeByCustomer, updateEmailByCustomer, getCustomerIdByEmail, getKeyByEmail } from "../services/keys.js"; const router = Router(); @@ -282,6 +282,146 @@ Use it with X-API-Key header or ?key= param.

} }); +/** + * @openapi + * /v1/billing/portal: + * post: + * tags: [Billing] + * summary: Create Stripe customer portal session + * description: Create a billing portal session for API key recovery and subscription management + * operationId: billingPortal + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [email] + * properties: + * email: + * type: string + * format: email + * description: Customer email address + * responses: + * 200: + * description: Portal session created + * content: + * application/json: + * schema: + * type: object + * properties: + * url: + * type: string + * format: uri + * description: Stripe customer portal URL + * 400: + * description: Missing or invalid email + * content: + * application/json: + * schema: { $ref: "#/components/schemas/Error" } + * 404: + * description: No subscription found for email + * content: + * application/json: + * schema: { $ref: "#/components/schemas/Error" } + * 500: + * description: Portal creation failed + * content: + * application/json: + * schema: { $ref: "#/components/schemas/Error" } + */ +router.post("/portal", async (req: Request, res: Response) => { + try { + const { email } = req.body; + + if (!email || typeof email !== 'string' || email.trim() === '') { + return res.status(400).json({ error: "Email address is required" }); + } + + const customerId = await getCustomerIdByEmail(email.trim()); + if (!customerId) { + return res.status(404).json({ + error: "No subscription found for this email address. Please contact support if you believe this is an error." + }); + } + + const session = await getStripe().billingPortal.sessions.create({ + customer: customerId, + return_url: `${BASE_URL}/#billing` + }); + + res.json({ url: session.url }); + } catch (err: any) { + logger.error({ err }, "Portal creation error"); + res.status(500).json({ error: "Failed to create portal session" }); + } +}); + +/** + * @openapi + * /v1/billing/recover: + * get: + * tags: [Billing] + * summary: Recover API key by email + * description: Recover API key for a customer by email address. Returns masked key for security. + * operationId: billingRecover + * parameters: + * - in: query + * name: email + * required: true + * schema: + * type: string + * format: email + * description: Customer email address + * responses: + * 200: + * description: Recovery processed (always returns success to prevent email enumeration) + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * description: Status message + * maskedKey: + * type: string + * description: Masked API key (only if key exists) + * 400: + * description: Missing or invalid email + * content: + * application/json: + * schema: { $ref: "#/components/schemas/Error" } + */ +router.get("/recover", async (req: Request, res: Response) => { + try { + const email = req.query.email as string; + + if (!email || typeof email !== 'string' || email.trim() === '') { + return res.status(400).json({ error: "Email address is required" }); + } + + const keyInfo = await getKeyByEmail(email.trim()); + const message = "If an account exists with this email, the API key has been sent."; + + if (!keyInfo) { + return res.json({ message }); + } + + // Mask the API key: show snap_ prefix + first 4 chars + ... + last 4 chars + const key = keyInfo.key; + const masked = `${key.substring(0, 9)}...${key.substring(key.length - 4)}`; + + // For now, just log the full key (TODO: implement email sending) + logger.info({ email: keyInfo.email, key }, "API key recovery requested"); + + res.json({ message, maskedKey: masked }); + } catch (err: any) { + logger.error({ err }, "Recovery error"); + res.status(500).json({ error: "Failed to process recovery request" }); + } +}); + /** * @openapi * /v1/billing/webhook: diff --git a/src/services/__tests__/keys.test.ts b/src/services/__tests__/keys.test.ts index cfddd07..b632600 100644 --- a/src/services/__tests__/keys.test.ts +++ b/src/services/__tests__/keys.test.ts @@ -1,5 +1,12 @@ -import { describe, it, expect } from 'vitest' -import { getTierLimit } from '../keys.js' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { getTierLimit, getKeyByEmail, getCustomerIdByEmail } from '../keys.js' + +// Mock the db module +vi.mock('../db.js', () => ({ + queryWithRetry: vi.fn() +})) + +import { queryWithRetry } from '../db.js' describe('getTierLimit', () => { it('should return 100 for free tier', () => { @@ -26,3 +33,94 @@ describe('getTierLimit', () => { expect(getTierLimit('')).toBe(100) }) }) + +describe('getKeyByEmail', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should return key info when email exists', async () => { + const mockRow = { + key: 'snap_abcd1234efgh5678', + tier: 'pro', + email: 'user@example.com', + created_at: '2024-01-01T00:00:00Z', + stripe_customer_id: 'cus_123456' + } + + vi.mocked(queryWithRetry).mockResolvedValue({ + rows: [mockRow] + }) + + const result = await getKeyByEmail('user@example.com') + + expect(result).toEqual({ + key: 'snap_abcd1234efgh5678', + tier: 'pro', + email: 'user@example.com', + createdAt: '2024-01-01T00:00:00Z', + stripeCustomerId: 'cus_123456' + }) + + expect(queryWithRetry).toHaveBeenCalledWith( + 'SELECT key, tier, email, created_at, stripe_customer_id FROM api_keys WHERE email = $1', + ['user@example.com'] + ) + }) + + it('should return undefined when email does not exist', async () => { + vi.mocked(queryWithRetry).mockResolvedValue({ + rows: [] + }) + + const result = await getKeyByEmail('nonexistent@example.com') + + expect(result).toBeUndefined() + }) + + it('should handle database errors gracefully', async () => { + vi.mocked(queryWithRetry).mockRejectedValue(new Error('Database error')) + + const result = await getKeyByEmail('user@example.com') + + expect(result).toBeUndefined() + }) +}) + +describe('getCustomerIdByEmail', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should return customer ID when email exists and has stripe customer', async () => { + vi.mocked(queryWithRetry).mockResolvedValue({ + rows: [{ stripe_customer_id: 'cus_123456' }] + }) + + const result = await getCustomerIdByEmail('user@example.com') + + expect(result).toBe('cus_123456') + expect(queryWithRetry).toHaveBeenCalledWith( + 'SELECT stripe_customer_id FROM api_keys WHERE email = $1 AND stripe_customer_id IS NOT NULL', + ['user@example.com'] + ) + }) + + it('should return undefined when email does not exist', async () => { + vi.mocked(queryWithRetry).mockResolvedValue({ + rows: [] + }) + + const result = await getCustomerIdByEmail('nonexistent@example.com') + + expect(result).toBeUndefined() + }) + + it('should handle database errors gracefully', async () => { + vi.mocked(queryWithRetry).mockRejectedValue(new Error('Database error')) + + const result = await getCustomerIdByEmail('user@example.com') + + expect(result).toBeUndefined() + }) +}) diff --git a/src/services/keys.ts b/src/services/keys.ts index cfd3b01..e07c0b0 100644 --- a/src/services/keys.ts +++ b/src/services/keys.ts @@ -205,3 +205,39 @@ export async function updateEmailByCustomer(customerId: string, newEmail: string if (k.stripeCustomerId === customerId) k.email = newEmail; } } + +export async function getKeyByEmail(email: string): Promise { + try { + const result = await queryWithRetry( + "SELECT key, tier, email, created_at, stripe_customer_id FROM api_keys WHERE email = $1", + [email] + ); + if (result.rows.length === 0) return undefined; + + const r = result.rows[0]; + return { + key: r.key, + tier: r.tier, + email: r.email, + createdAt: r.created_at instanceof Date ? r.created_at.toISOString() : r.created_at, + stripeCustomerId: r.stripe_customer_id || undefined, + }; + } catch (err) { + logger.error({ err }, "Failed to get key by email"); + return undefined; + } +} + +export async function getCustomerIdByEmail(email: string): Promise { + try { + const result = await queryWithRetry( + "SELECT stripe_customer_id FROM api_keys WHERE email = $1 AND stripe_customer_id IS NOT NULL", + [email] + ); + if (result.rows.length === 0) return undefined; + return result.rows[0].stripe_customer_id; + } catch (err) { + logger.error({ err }, "Failed to get customer ID by email"); + return undefined; + } +}