diff --git a/apps/web/src/app/api/usage/topup/__tests__/route.test.ts b/apps/web/src/app/api/usage/topup/__tests__/route.test.ts new file mode 100644 index 0000000..5d4f2fb --- /dev/null +++ b/apps/web/src/app/api/usage/topup/__tests__/route.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// ─── Mocks ─── + +const mockGetUser = vi.fn(); +const mockInsert = vi.fn(); +const mockCreatePayment = vi.fn(); + +vi.mock("@/lib/supabase", () => ({ + getSupabaseClient: () => ({ + auth: { getUser: mockGetUser }, + }), + getSupabaseAdmin: () => ({ + from: () => ({ insert: mockInsert }), + }), +})); + +vi.mock("@/lib/coinpay-client", () => ({ + createCoinpayPayment: mockCreatePayment, +})); + +import { POST } from "@/app/api/usage/topup/route"; + +function makeRequest(body: unknown) { + return new Request("http://localhost/api/usage/topup", { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: "Bearer tok-abc" }, + body: JSON.stringify(body), + }) as unknown as import("next/server").NextRequest; +} + +describe("POST /api/usage/topup", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetUser.mockResolvedValue({ + data: { user: { id: "user-123", email: "test@example.com" } }, + error: null, + }); + mockCreatePayment.mockResolvedValue({ + payment: { id: "cp-1", payment_address: "0xabc", amount_crypto: 50 }, + checkout_url: "https://pay/cp-1", + }); + mockInsert.mockResolvedValue({ error: null }); + }); + + it("rejects a non-numeric amount_usd instead of forwarding a garbage amount", async () => { + const res = await POST(makeRequest({ amount_usd: "abc", currency: "usdc_sol" })); + const body = await res.json(); + expect(res.status).toBe(400); + expect(body.error).toContain("between $1 and $10,000"); + expect(mockCreatePayment).not.toHaveBeenCalled(); + expect(mockInsert).not.toHaveBeenCalled(); + }); + + it("rejects out-of-range amounts", async () => { + expect((await POST(makeRequest({ amount_usd: 0, currency: "usdc_sol" }))).status).toBe(400); + expect((await POST(makeRequest({ amount_usd: 20000, currency: "usdc_sol" }))).status).toBe(400); + expect(mockCreatePayment).not.toHaveBeenCalled(); + }); + + it("accepts a valid amount and passes a NUMBER to CoinPay and the deposit row", async () => { + const res = await POST(makeRequest({ amount_usd: 50, currency: "usdc_sol" })); + expect(res.status).toBe(200); + expect(mockCreatePayment).toHaveBeenCalledWith(expect.objectContaining({ amount_usd: 50 })); + expect(mockInsert).toHaveBeenCalledWith(expect.objectContaining({ amount_usd: 50 })); + }); + + it("coerces a numeric string to a number", async () => { + const res = await POST(makeRequest({ amount_usd: "50", currency: "usdc_sol" })); + expect(res.status).toBe(200); + expect(mockCreatePayment).toHaveBeenCalledWith(expect.objectContaining({ amount_usd: 50 })); + }); +}); diff --git a/apps/web/src/app/api/usage/topup/route.ts b/apps/web/src/app/api/usage/topup/route.ts index 6a51fe6..929ff90 100644 --- a/apps/web/src/app/api/usage/topup/route.ts +++ b/apps/web/src/app/api/usage/topup/route.ts @@ -18,13 +18,15 @@ export async function POST(request: NextRequest) { } const body = await request.json(); - const { amount_usd, currency } = body; + const { currency } = body; - if (!amount_usd) { - return NextResponse.json({ error: "amount_usd required" }, { status: 400 }); - } - - if (amount_usd < 1 || amount_usd > 10000) { + // Coerce and validate the amount (mirrors funding/create-invoice/route.ts). + // Without Number()/isFinite, a non-numeric amount_usd like "abc" is truthy + // (so it passes the presence check) and both "abc" < 1 and "abc" > 10000 are + // false, so it slipped through and was sent to CoinPay / stored on the + // credit_deposits row as a garbage amount. + const amount_usd = Number(body.amount_usd); + if (!Number.isFinite(amount_usd) || amount_usd < 1 || amount_usd > 10000) { return NextResponse.json({ error: "Amount must be between $1 and $10,000" }, { status: 400 }); }