diff --git a/package.json b/package.json index eae3dd6..92c0e1f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "snapapi", - "version": "0.6.0", + "version": "0.7.0", "description": "URL to Screenshot API — PNG, JPEG, WebP via simple REST API", "main": "dist/index.js", "type": "module", diff --git a/src/routes/__tests__/billing.test.ts b/src/routes/__tests__/billing.test.ts index 8392613..ec3db84 100644 --- a/src/routes/__tests__/billing.test.ts +++ b/src/routes/__tests__/billing.test.ts @@ -256,6 +256,34 @@ describe('GET /v1/billing/success', () => { }) }) +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() }) diff --git a/src/routes/billing.ts b/src/routes/billing.ts index 9f4421a..d37e92c 100644 --- a/src/routes/billing.ts +++ b/src/routes/billing.ts @@ -1,10 +1,21 @@ import { Router, Request, Response } from "express"; import Stripe from "stripe"; +import rateLimit from "express-rate-limit"; import logger from "../services/logger.js"; import { createPaidKey, downgradeByCustomer, updateEmailByCustomer, getCustomerIdByEmail, getKeyByEmail } from "../services/keys.js"; const router = Router(); +const billingLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: process.env.NODE_ENV === "test" ? 1000 : 10, + message: { error: "Too many requests. Please try again later." }, + standardHeaders: true, + legacyHeaders: false, + skip: (req: Request) => req.path === "/webhook", +}); +router.use(billingLimiter); + let _stripe: Stripe | null = null; function getStripe(): Stripe { if (!_stripe) { @@ -413,7 +424,7 @@ router.get("/recover", async (req: Request, res: Response) => { 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"); + logger.info({ email: keyInfo.email }, "API key recovery requested"); res.json({ message, maskedKey: masked }); } catch (err: any) { diff --git a/src/services/__tests__/keys.test.ts b/src/services/__tests__/keys.test.ts index b632600..44f4c87 100644 --- a/src/services/__tests__/keys.test.ts +++ b/src/services/__tests__/keys.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { getTierLimit, getKeyByEmail, getCustomerIdByEmail } from '../keys.js' +import { getTierLimit, getKeyByEmail, getCustomerIdByEmail, downgradeByCustomer } from '../keys.js' // Mock the db module vi.mock('../db.js', () => ({ @@ -25,6 +25,10 @@ describe('getTierLimit', () => { expect(getTierLimit('business')).toBe(25000) }) + it('should return 0 for cancelled tier', () => { + expect(getTierLimit('cancelled')).toBe(0) + }) + it('should return 100 for unknown tier', () => { expect(getTierLimit('enterprise')).toBe(100) }) @@ -124,3 +128,20 @@ describe('getCustomerIdByEmail', () => { expect(result).toBeUndefined() }) }) + +describe('downgradeByCustomer', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should set tier to cancelled instead of free', async () => { + vi.mocked(queryWithRetry).mockResolvedValue({ rows: [] }) + + await downgradeByCustomer('cus_test_123') + + expect(queryWithRetry).toHaveBeenCalledWith( + expect.stringContaining("'cancelled'"), + ['cus_test_123'] + ) + }) +}) diff --git a/src/services/keys.ts b/src/services/keys.ts index e07c0b0..52a550d 100644 --- a/src/services/keys.ts +++ b/src/services/keys.ts @@ -4,7 +4,7 @@ import { queryWithRetry } from "./db.js"; export interface ApiKey { key: string; - tier: "free" | "starter" | "pro" | "business"; + tier: "free" | "cancelled" | "starter" | "pro" | "business"; email: string; createdAt: string; stripeCustomerId?: string; @@ -130,6 +130,7 @@ export function getAllKeys(): ApiKey[] { export function getTierLimit(tier: string): number { switch (tier) { case "free": return 100; + case "cancelled": return 0; case "starter": return 1000; case "pro": return 5000; case "business": return 25000; @@ -184,16 +185,16 @@ export async function createPaidKey(email: string, tier: "starter" | "pro" | "bu export async function downgradeByCustomer(customerId: string): Promise { await queryWithRetry( - "UPDATE api_keys SET tier = 'free', stripe_customer_id = NULL WHERE stripe_customer_id = $1", + "UPDATE api_keys SET tier = 'cancelled', stripe_customer_id = NULL WHERE stripe_customer_id = $1", [customerId] ); for (const k of keysCache) { if (k.stripeCustomerId === customerId) { - k.tier = "free"; + k.tier = "cancelled"; k.stripeCustomerId = undefined; } } - logger.info({ customerId }, "Downgraded customer to free"); + logger.info({ customerId }, "Downgraded customer to cancelled"); } export async function updateEmailByCustomer(customerId: string, newEmail: string): Promise {