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

- 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:
OpenClawd 2026-02-24 16:26:54 +00:00
parent c3dabc2ac6
commit f696cb36db
3 changed files with 171 additions and 14 deletions

View 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')
})
})

View file

@ -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<boolean> {
@ -91,7 +102,7 @@ async function isSnapAPIEvent(event: Stripe.Event): Promise<boolean> {
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<boolean> {
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") {

View 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)
})
})