fix: cancelled tier, remove key logging, add billing rate limits
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 10m13s
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 10m13s
- 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)
This commit is contained in:
parent
f3a363fb17
commit
9575d312fe
5 changed files with 68 additions and 7 deletions
|
|
@ -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() })
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue