Skip to content

Commit 84646c0

Browse files
fix(usage): coerce and validate topup amount_usd as a finite number (#40)
/api/usage/topup read amount_usd raw from JSON and checked only `!amount_usd` then `amount_usd < 1 || amount_usd > 10000`. A non-numeric value like "abc" is truthy and both "abc" < 1 and "abc" > 10000 are false, so it slipped through and was forwarded to CoinPay and inserted into credit_deposits as a garbage amount (a numeric string like "50" was likewise stored as a string). Coerce with Number() and guard with Number.isFinite, matching the existing pattern in funding/create-invoice/route.ts. Add tests covering non-numeric, out-of-range, valid-number and numeric-string inputs.
1 parent 634c6cb commit 84646c0

2 files changed

Lines changed: 81 additions & 6 deletions

File tree

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest";
2+
3+
// ─── Mocks ───
4+
5+
const mockGetUser = vi.fn();
6+
const mockInsert = vi.fn();
7+
const mockCreatePayment = vi.fn();
8+
9+
vi.mock("@/lib/supabase", () => ({
10+
getSupabaseClient: () => ({
11+
auth: { getUser: mockGetUser },
12+
}),
13+
getSupabaseAdmin: () => ({
14+
from: () => ({ insert: mockInsert }),
15+
}),
16+
}));
17+
18+
vi.mock("@/lib/coinpay-client", () => ({
19+
createCoinpayPayment: mockCreatePayment,
20+
}));
21+
22+
import { POST } from "@/app/api/usage/topup/route";
23+
24+
function makeRequest(body: unknown) {
25+
return new Request("http://localhost/api/usage/topup", {
26+
method: "POST",
27+
headers: { "Content-Type": "application/json", Authorization: "Bearer tok-abc" },
28+
body: JSON.stringify(body),
29+
}) as unknown as import("next/server").NextRequest;
30+
}
31+
32+
describe("POST /api/usage/topup", () => {
33+
beforeEach(() => {
34+
vi.clearAllMocks();
35+
mockGetUser.mockResolvedValue({
36+
data: { user: { id: "user-123", email: "test@example.com" } },
37+
error: null,
38+
});
39+
mockCreatePayment.mockResolvedValue({
40+
payment: { id: "cp-1", payment_address: "0xabc", amount_crypto: 50 },
41+
checkout_url: "https://pay/cp-1",
42+
});
43+
mockInsert.mockResolvedValue({ error: null });
44+
});
45+
46+
it("rejects a non-numeric amount_usd instead of forwarding a garbage amount", async () => {
47+
const res = await POST(makeRequest({ amount_usd: "abc", currency: "usdc_sol" }));
48+
const body = await res.json();
49+
expect(res.status).toBe(400);
50+
expect(body.error).toContain("between $1 and $10,000");
51+
expect(mockCreatePayment).not.toHaveBeenCalled();
52+
expect(mockInsert).not.toHaveBeenCalled();
53+
});
54+
55+
it("rejects out-of-range amounts", async () => {
56+
expect((await POST(makeRequest({ amount_usd: 0, currency: "usdc_sol" }))).status).toBe(400);
57+
expect((await POST(makeRequest({ amount_usd: 20000, currency: "usdc_sol" }))).status).toBe(400);
58+
expect(mockCreatePayment).not.toHaveBeenCalled();
59+
});
60+
61+
it("accepts a valid amount and passes a NUMBER to CoinPay and the deposit row", async () => {
62+
const res = await POST(makeRequest({ amount_usd: 50, currency: "usdc_sol" }));
63+
expect(res.status).toBe(200);
64+
expect(mockCreatePayment).toHaveBeenCalledWith(expect.objectContaining({ amount_usd: 50 }));
65+
expect(mockInsert).toHaveBeenCalledWith(expect.objectContaining({ amount_usd: 50 }));
66+
});
67+
68+
it("coerces a numeric string to a number", async () => {
69+
const res = await POST(makeRequest({ amount_usd: "50", currency: "usdc_sol" }));
70+
expect(res.status).toBe(200);
71+
expect(mockCreatePayment).toHaveBeenCalledWith(expect.objectContaining({ amount_usd: 50 }));
72+
});
73+
});

apps/web/src/app/api/usage/topup/route.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,15 @@ export async function POST(request: NextRequest) {
1818
}
1919

2020
const body = await request.json();
21-
const { amount_usd, currency } = body;
21+
const { currency } = body;
2222

23-
if (!amount_usd) {
24-
return NextResponse.json({ error: "amount_usd required" }, { status: 400 });
25-
}
26-
27-
if (amount_usd < 1 || amount_usd > 10000) {
23+
// Coerce and validate the amount (mirrors funding/create-invoice/route.ts).
24+
// Without Number()/isFinite, a non-numeric amount_usd like "abc" is truthy
25+
// (so it passes the presence check) and both "abc" < 1 and "abc" > 10000 are
26+
// false, so it slipped through and was sent to CoinPay / stored on the
27+
// credit_deposits row as a garbage amount.
28+
const amount_usd = Number(body.amount_usd);
29+
if (!Number.isFinite(amount_usd) || amount_usd < 1 || amount_usd > 10000) {
2830
return NextResponse.json({ error: "Amount must be between $1 and $10,000" }, { status: 400 });
2931
}
3032

0 commit comments

Comments
 (0)