diff --git a/src/middleware/__tests__/auth.test.ts b/src/middleware/__tests__/auth.test.ts new file mode 100644 index 0000000..1d2e587 --- /dev/null +++ b/src/middleware/__tests__/auth.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { authMiddleware } from '../auth.js' + +// Mock the keys service +vi.mock('../../services/keys.js', () => ({ + isValidKey: vi.fn(), + getKeyInfo: vi.fn(), +})) + +const { isValidKey, getKeyInfo } = await import('../../services/keys.js') +const mockIsValidKey = vi.mocked(isValidKey) +const mockGetKeyInfo = vi.mocked(getKeyInfo) + +function mockReq(overrides: any = {}): any { + return { + headers: {}, + query: {}, + ...overrides, + } +} + +function mockRes(): any { + const res: any = {} + res.status = vi.fn().mockReturnValue(res) + res.json = vi.fn().mockReturnValue(res) + return res +} + +describe('authMiddleware', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should return 401 when no API key is provided', async () => { + const req = mockReq() + const res = mockRes() + const next = vi.fn() + + await authMiddleware(req, res, next) + + expect(res.status).toHaveBeenCalledWith(401) + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: expect.stringContaining('Missing API key') })) + expect(next).not.toHaveBeenCalled() + }) + + it('should extract key from Bearer authorization header', async () => { + mockIsValidKey.mockResolvedValueOnce(true) + mockGetKeyInfo.mockResolvedValueOnce({ key: 'snap_abc123', tier: 'pro', email: 'test@test.com', createdAt: '2024-01-01' }) + + const req = mockReq({ headers: { authorization: 'Bearer snap_abc123' } }) + const res = mockRes() + const next = vi.fn() + + await authMiddleware(req, res, next) + + expect(mockIsValidKey).toHaveBeenCalledWith('snap_abc123') + expect(next).toHaveBeenCalled() + expect(req.apiKeyInfo).toBeDefined() + }) + + it('should extract key from X-API-Key header', async () => { + mockIsValidKey.mockResolvedValueOnce(true) + mockGetKeyInfo.mockResolvedValueOnce({ key: 'snap_xyz789', tier: 'starter', email: 'a@b.com', createdAt: '2024-01-01' }) + + const req = mockReq({ headers: { 'x-api-key': 'snap_xyz789' } }) + const res = mockRes() + const next = vi.fn() + + await authMiddleware(req, res, next) + + expect(mockIsValidKey).toHaveBeenCalledWith('snap_xyz789') + expect(next).toHaveBeenCalled() + }) + + it('should extract key from query parameter', async () => { + mockIsValidKey.mockResolvedValueOnce(true) + mockGetKeyInfo.mockResolvedValueOnce({ key: 'snap_qp1', tier: 'business', email: 'c@d.com', createdAt: '2024-01-01' }) + + const req = mockReq({ query: { key: 'snap_qp1' } }) + const res = mockRes() + const next = vi.fn() + + await authMiddleware(req, res, next) + + expect(mockIsValidKey).toHaveBeenCalledWith('snap_qp1') + expect(next).toHaveBeenCalled() + }) + + it('should return 403 for invalid API key', async () => { + mockIsValidKey.mockResolvedValueOnce(false) + + const req = mockReq({ headers: { 'x-api-key': 'invalid_key' } }) + const res = mockRes() + const next = vi.fn() + + await authMiddleware(req, res, next) + + expect(res.status).toHaveBeenCalledWith(403) + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: expect.stringContaining('Invalid API key') })) + expect(next).not.toHaveBeenCalled() + }) + + it('should prefer Bearer header over X-API-Key and query', async () => { + mockIsValidKey.mockResolvedValueOnce(true) + mockGetKeyInfo.mockResolvedValueOnce({ key: 'snap_bearer', tier: 'pro', email: 'e@f.com', createdAt: '2024-01-01' }) + + const req = mockReq({ + headers: { authorization: 'Bearer snap_bearer', 'x-api-key': 'snap_xapi' }, + query: { key: 'snap_query' } + }) + const res = mockRes() + const next = vi.fn() + + await authMiddleware(req, res, next) + + expect(mockIsValidKey).toHaveBeenCalledWith('snap_bearer') + }) +}) diff --git a/src/routes/billing.ts b/src/routes/billing.ts index 47cb982..9a7f058 100644 --- a/src/routes/billing.ts +++ b/src/routes/billing.ts @@ -5,12 +5,21 @@ import { createPaidKey, downgradeByCustomer, updateEmailByCustomer } from "../se const router = Router(); -const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { - apiVersion: "2025-01-27.acacia" as any, -}); +let _stripe: Stripe | null = null; +function getStripe(): Stripe { + if (!_stripe) { + if (!process.env.STRIPE_SECRET_KEY) { + throw new Error("STRIPE_SECRET_KEY environment variable is not set"); + } + _stripe = new Stripe(process.env.STRIPE_SECRET_KEY, { + apiVersion: "2025-01-27.acacia" as any, + }); + } + return _stripe; +} const BASE_URL = process.env.BASE_URL || "https://snapapi.eu"; -const WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET!; +const WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET || ""; // DocFast product ID — NEVER process events for this const DOCFAST_PRODUCT_ID = "prod_TygeG8tQPtEAdE"; @@ -33,27 +42,27 @@ async function getOrCreatePrice(name: string, amount: number, description: strin if (priceCache[name]) return priceCache[name]; // Search for existing product by name - const products = await stripe.products.search({ query: `name:"${name}"` }); + const products = await getStripe().products.search({ query: `name:"${name}"` }); let product: Stripe.Product; if (products.data.length > 0) { product = products.data[0]; logger.info({ productId: product.id, name }, "Found existing Stripe product"); } else { - product = await stripe.products.create({ name, description }); + product = await getStripe().products.create({ name, description }); logger.info({ productId: product.id, name }, "Created Stripe product"); } snapapiProductIds.add(product.id); // Check for existing price - const prices = await stripe.prices.list({ product: product.id, active: true, limit: 1 }); + const prices = await getStripe().prices.list({ product: product.id, active: true, limit: 1 }); if (prices.data.length > 0 && prices.data[0].unit_amount === amount) { priceCache[name] = prices.data[0].id; return prices.data[0].id; } - const price = await stripe.prices.create({ + const price = await getStripe().prices.create({ product: product.id, unit_amount: amount, currency: "eur", @@ -77,7 +86,9 @@ async function initPrices() { logger.info({ productIds: [...snapapiProductIds], prices: { ...priceCache } }, "SnapAPI Stripe products initialized"); } -initPrices().catch(err => logger.error({ err }, "Failed to initialize Stripe prices")); +if (process.env.STRIPE_SECRET_KEY) { + initPrices().catch(err => logger.error({ err }, "Failed to initialize Stripe prices")); +} // Helper: check if event belongs to SnapAPI async function isSnapAPIEvent(event: Stripe.Event): Promise { @@ -91,7 +102,7 @@ async function isSnapAPIEvent(event: Stripe.Event): Promise { if (event.type === "checkout.session.completed") { const session = obj as Stripe.Checkout.Session; if (session.subscription) { - const sub = await stripe.subscriptions.retrieve(session.subscription as string, { expand: ["items.data.price.product"] }); + const sub = await getStripe().subscriptions.retrieve(session.subscription as string, { expand: ["items.data.price.product"] }); const item = sub.items.data[0]; const prod = item?.price?.product; productId = typeof prod === "string" ? prod : prod?.id; @@ -107,7 +118,7 @@ async function isSnapAPIEvent(event: Stripe.Event): Promise { productId = typeof prod === "string" ? prod : (prod as any)?.id; // If product not expanded, fetch it if (!productId && item.price?.id) { - const price = await stripe.prices.retrieve(item.price.id); + const price = await getStripe().prices.retrieve(item.price.id); productId = typeof price.product === "string" ? price.product : (price.product as any)?.id; } } @@ -175,7 +186,7 @@ router.post("/checkout", async (req: Request, res: Response) => { const planDef = PLANS[plan]; const priceId = await getOrCreatePrice(planDef.name, planDef.amount, planDef.description); - const session = await stripe.checkout.sessions.create({ + const session = await getStripe().checkout.sessions.create({ mode: "subscription", payment_method_types: ["card"], line_items: [{ price: priceId, quantity: 1 }], @@ -222,7 +233,7 @@ router.get("/success", async (req: Request, res: Response) => { const sessionId = req.query.session_id as string; if (!sessionId) return res.status(400).send("Missing session_id"); - const session = await stripe.checkout.sessions.retrieve(sessionId, { expand: ["subscription"] }); + const session = await getStripe().checkout.sessions.retrieve(sessionId, { expand: ["subscription"] }); const email = session.customer_details?.email || session.customer_email || "unknown@snapapi.eu"; const plan = session.metadata?.plan || "starter"; const tier = PLANS[plan]?.tier || "starter"; @@ -306,7 +317,7 @@ router.post("/webhook", async (req: Request, res: Response) => { const sig = req.headers["stripe-signature"] as string; if (!sig) return res.status(400).send("Missing signature"); - const event = stripe.webhooks.constructEvent(req.body, sig, WEBHOOK_SECRET); + const event = getStripe().webhooks.constructEvent(req.body, sig, WEBHOOK_SECRET); // Filter: only process SnapAPI events if (event.type !== "customer.updated") { diff --git a/src/services/__tests__/keys.test.ts b/src/services/__tests__/keys.test.ts new file mode 100644 index 0000000..cfddd07 --- /dev/null +++ b/src/services/__tests__/keys.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest' +import { getTierLimit } from '../keys.js' + +describe('getTierLimit', () => { + it('should return 100 for free tier', () => { + expect(getTierLimit('free')).toBe(100) + }) + + it('should return 1000 for starter tier', () => { + expect(getTierLimit('starter')).toBe(1000) + }) + + it('should return 5000 for pro tier', () => { + expect(getTierLimit('pro')).toBe(5000) + }) + + it('should return 25000 for business tier', () => { + expect(getTierLimit('business')).toBe(25000) + }) + + it('should return 100 for unknown tier', () => { + expect(getTierLimit('enterprise')).toBe(100) + }) + + it('should return 100 for empty string', () => { + expect(getTierLimit('')).toBe(100) + }) +})