fix: lazy Stripe init (unblocks test suite) + add auth/keys unit tests
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 9m47s
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 9m47s
- billing.ts: Stripe now initialized lazily via getStripe() instead of at module load This prevents test suite crash when STRIPE_SECRET_KEY env var is not set - Add src/middleware/__tests__/auth.test.ts (6 tests): key extraction from Bearer header, X-API-Key header, query param; 401/403 responses; priority order - Add src/services/__tests__/keys.test.ts (6 tests): getTierLimit for all tiers - Total: 61 tests passing, 0 failures
This commit is contained in:
parent
c3dabc2ac6
commit
f696cb36db
3 changed files with 171 additions and 14 deletions
118
src/middleware/__tests__/auth.test.ts
Normal file
118
src/middleware/__tests__/auth.test.ts
Normal file
|
|
@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -5,12 +5,21 @@ import { createPaidKey, downgradeByCustomer, updateEmailByCustomer } from "../se
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
|
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,
|
apiVersion: "2025-01-27.acacia" as any,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
return _stripe;
|
||||||
|
}
|
||||||
|
|
||||||
const BASE_URL = process.env.BASE_URL || "https://snapapi.eu";
|
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
|
// DocFast product ID — NEVER process events for this
|
||||||
const DOCFAST_PRODUCT_ID = "prod_TygeG8tQPtEAdE";
|
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];
|
if (priceCache[name]) return priceCache[name];
|
||||||
|
|
||||||
// Search for existing product by 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;
|
let product: Stripe.Product;
|
||||||
|
|
||||||
if (products.data.length > 0) {
|
if (products.data.length > 0) {
|
||||||
product = products.data[0];
|
product = products.data[0];
|
||||||
logger.info({ productId: product.id, name }, "Found existing Stripe product");
|
logger.info({ productId: product.id, name }, "Found existing Stripe product");
|
||||||
} else {
|
} else {
|
||||||
product = await stripe.products.create({ name, description });
|
product = await getStripe().products.create({ name, description });
|
||||||
logger.info({ productId: product.id, name }, "Created Stripe product");
|
logger.info({ productId: product.id, name }, "Created Stripe product");
|
||||||
}
|
}
|
||||||
|
|
||||||
snapapiProductIds.add(product.id);
|
snapapiProductIds.add(product.id);
|
||||||
|
|
||||||
// Check for existing price
|
// 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) {
|
if (prices.data.length > 0 && prices.data[0].unit_amount === amount) {
|
||||||
priceCache[name] = prices.data[0].id;
|
priceCache[name] = prices.data[0].id;
|
||||||
return prices.data[0].id;
|
return prices.data[0].id;
|
||||||
}
|
}
|
||||||
|
|
||||||
const price = await stripe.prices.create({
|
const price = await getStripe().prices.create({
|
||||||
product: product.id,
|
product: product.id,
|
||||||
unit_amount: amount,
|
unit_amount: amount,
|
||||||
currency: "eur",
|
currency: "eur",
|
||||||
|
|
@ -77,7 +86,9 @@ async function initPrices() {
|
||||||
logger.info({ productIds: [...snapapiProductIds], prices: { ...priceCache } }, "SnapAPI Stripe products initialized");
|
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
|
// Helper: check if event belongs to SnapAPI
|
||||||
async function isSnapAPIEvent(event: Stripe.Event): Promise<boolean> {
|
async function isSnapAPIEvent(event: Stripe.Event): Promise<boolean> {
|
||||||
|
|
@ -91,7 +102,7 @@ async function isSnapAPIEvent(event: Stripe.Event): Promise<boolean> {
|
||||||
if (event.type === "checkout.session.completed") {
|
if (event.type === "checkout.session.completed") {
|
||||||
const session = obj as Stripe.Checkout.Session;
|
const session = obj as Stripe.Checkout.Session;
|
||||||
if (session.subscription) {
|
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 item = sub.items.data[0];
|
||||||
const prod = item?.price?.product;
|
const prod = item?.price?.product;
|
||||||
productId = typeof prod === "string" ? prod : prod?.id;
|
productId = typeof prod === "string" ? prod : prod?.id;
|
||||||
|
|
@ -107,7 +118,7 @@ async function isSnapAPIEvent(event: Stripe.Event): Promise<boolean> {
|
||||||
productId = typeof prod === "string" ? prod : (prod as any)?.id;
|
productId = typeof prod === "string" ? prod : (prod as any)?.id;
|
||||||
// If product not expanded, fetch it
|
// If product not expanded, fetch it
|
||||||
if (!productId && item.price?.id) {
|
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;
|
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 planDef = PLANS[plan];
|
||||||
const priceId = await getOrCreatePrice(planDef.name, planDef.amount, planDef.description);
|
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",
|
mode: "subscription",
|
||||||
payment_method_types: ["card"],
|
payment_method_types: ["card"],
|
||||||
line_items: [{ price: priceId, quantity: 1 }],
|
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;
|
const sessionId = req.query.session_id as string;
|
||||||
if (!sessionId) return res.status(400).send("Missing session_id");
|
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 email = session.customer_details?.email || session.customer_email || "unknown@snapapi.eu";
|
||||||
const plan = session.metadata?.plan || "starter";
|
const plan = session.metadata?.plan || "starter";
|
||||||
const tier = PLANS[plan]?.tier || "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;
|
const sig = req.headers["stripe-signature"] as string;
|
||||||
if (!sig) return res.status(400).send("Missing signature");
|
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
|
// Filter: only process SnapAPI events
|
||||||
if (event.type !== "customer.updated") {
|
if (event.type !== "customer.updated") {
|
||||||
|
|
|
||||||
28
src/services/__tests__/keys.test.ts
Normal file
28
src/services/__tests__/keys.test.ts
Normal file
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue