Skip to content

Commit 8113de3

Browse files
morganschpMorgan Penny
andauthored
Handle malformed wallet deposit bodies (#187)
Co-authored-by: Morgan Penny <morgan@Morgans-Macbook-2.local>
1 parent 3ca2e7a commit 8113de3

2 files changed

Lines changed: 169 additions & 2 deletions

File tree

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest";
2+
3+
const mockGetAuthContext = vi.fn();
4+
vi.mock("@/lib/auth/get-user", () => ({
5+
getAuthContext: (...args: any[]) => mockGetAuthContext(...args),
6+
}));
7+
8+
const mockCreateServiceClient = vi.fn();
9+
vi.mock("@/lib/supabase/service", () => ({
10+
createServiceClient: () => mockCreateServiceClient(),
11+
}));
12+
13+
const mockGetUserLnWallet = vi.fn();
14+
const mockCreateInvoice = vi.fn();
15+
const mockGetLnBalance = vi.fn();
16+
vi.mock("@/lib/lightning/wallet-utils", () => ({
17+
getUserLnWallet: (...args: any[]) => mockGetUserLnWallet(...args),
18+
createInvoice: (...args: any[]) => mockCreateInvoice(...args),
19+
getLnBalance: (...args: any[]) => mockGetLnBalance(...args),
20+
}));
21+
22+
import { POST } from "./route";
23+
24+
function makeRequest(body: any) {
25+
return new Request("http://localhost/api/wallet/deposit", {
26+
method: "POST",
27+
headers: { "Content-Type": "application/json" },
28+
body: JSON.stringify(body),
29+
}) as any;
30+
}
31+
32+
function makeRawRequest(body: string) {
33+
return new Request("http://localhost/api/wallet/deposit", {
34+
method: "POST",
35+
headers: { "Content-Type": "application/json" },
36+
body,
37+
}) as any;
38+
}
39+
40+
describe("POST /api/wallet/deposit", () => {
41+
beforeEach(() => {
42+
vi.clearAllMocks();
43+
mockGetAuthContext.mockResolvedValue({ user: { id: "user-12345678" } });
44+
mockGetUserLnWallet.mockResolvedValue({
45+
invoice_key: "invoice-key-123",
46+
});
47+
mockCreateInvoice.mockResolvedValue({
48+
payment_request: "lnbc123",
49+
payment_hash: "hash-123",
50+
});
51+
mockGetLnBalance.mockResolvedValue(250);
52+
});
53+
54+
it("rejects unauthenticated requests", async () => {
55+
mockGetAuthContext.mockResolvedValue(null);
56+
57+
const res = await POST(makeRequest({ amount_sats: 100 }));
58+
59+
expect(res.status).toBe(401);
60+
expect(mockCreateServiceClient).not.toHaveBeenCalled();
61+
});
62+
63+
it("rejects malformed JSON without creating an invoice", async () => {
64+
const res = await POST(makeRawRequest("{not valid json"));
65+
66+
expect(res.status).toBe(400);
67+
const data = await res.json();
68+
expect(data.error).toBe("Invalid request body");
69+
expect(mockCreateServiceClient).not.toHaveBeenCalled();
70+
expect(mockCreateInvoice).not.toHaveBeenCalled();
71+
});
72+
73+
it("rejects non-object JSON bodies without creating an invoice", async () => {
74+
const res = await POST(makeRequest(null));
75+
76+
expect(res.status).toBe(400);
77+
const data = await res.json();
78+
expect(data.error).toBe("Invalid request body");
79+
expect(mockCreateServiceClient).not.toHaveBeenCalled();
80+
expect(mockCreateInvoice).not.toHaveBeenCalled();
81+
});
82+
83+
it("rejects non-integer amounts without wallet lookup", async () => {
84+
const res = await POST(makeRequest({ amount_sats: 100.5 }));
85+
86+
expect(res.status).toBe(400);
87+
const data = await res.json();
88+
expect(data.error).toBe("Invalid amount (1-1,000,000 sats)");
89+
expect(mockCreateServiceClient).not.toHaveBeenCalled();
90+
expect(mockGetUserLnWallet).not.toHaveBeenCalled();
91+
});
92+
93+
it("creates an invoice for a valid deposit amount", async () => {
94+
const walletSelectSingle = vi.fn().mockResolvedValue({
95+
data: { id: "wallet-1", balance_sats: 250 },
96+
});
97+
const walletEq = vi.fn().mockReturnValue({ single: walletSelectSingle });
98+
const walletSelect = vi.fn().mockReturnValue({ eq: walletEq });
99+
const transactionInsert = vi.fn().mockResolvedValue({ data: null });
100+
const admin = {
101+
from: vi.fn((table: string) => {
102+
if (table === "wallets") {
103+
return {
104+
select: walletSelect,
105+
insert: vi.fn().mockResolvedValue({ data: null }),
106+
};
107+
}
108+
if (table === "wallet_transactions") {
109+
return { insert: transactionInsert };
110+
}
111+
return {};
112+
}),
113+
};
114+
mockCreateServiceClient.mockReturnValue(admin);
115+
116+
const res = await POST(makeRequest({ amount_sats: 100 }));
117+
118+
expect(res.status).toBe(200);
119+
const data = await res.json();
120+
expect(data).toEqual({
121+
ok: true,
122+
payment_request: "lnbc123",
123+
payment_hash: "hash-123",
124+
amount_sats: 100,
125+
});
126+
expect(mockCreateInvoice).toHaveBeenCalledWith(
127+
"invoice-key-123",
128+
100,
129+
"ugig.net deposit (user-123)",
130+
);
131+
expect(transactionInsert).toHaveBeenCalledWith({
132+
user_id: "user-12345678",
133+
type: "deposit",
134+
amount_sats: 100,
135+
balance_after: 250,
136+
bolt11: "lnbc123",
137+
payment_hash: "hash-123",
138+
status: "pending",
139+
});
140+
});
141+
});

src/app/api/wallet/deposit/route.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,41 @@ import { getAuthContext } from "@/lib/auth/get-user";
33
import { createServiceClient } from "@/lib/supabase/service";
44
import { getUserLnWallet, createInvoice, getLnBalance } from "@/lib/lightning/wallet-utils";
55

6+
type DepositRequestBody = {
7+
amount_sats?: unknown;
8+
};
9+
10+
async function parseDepositRequestBody(request: NextRequest): Promise<DepositRequestBody | null> {
11+
try {
12+
const body = await request.json();
13+
if (!body || typeof body !== "object" || Array.isArray(body)) {
14+
return null;
15+
}
16+
return body as DepositRequestBody;
17+
} catch {
18+
return null;
19+
}
20+
}
21+
622
export async function POST(request: NextRequest) {
723
try {
824
const auth = await getAuthContext(request);
925
if (!auth) {
1026
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
1127
}
1228

13-
const { amount_sats } = await request.json();
14-
if (!amount_sats || amount_sats <= 0 || amount_sats > 1000000) {
29+
const body = await parseDepositRequestBody(request);
30+
if (!body) {
31+
return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
32+
}
33+
34+
const { amount_sats } = body;
35+
if (
36+
typeof amount_sats !== "number" ||
37+
!Number.isInteger(amount_sats) ||
38+
amount_sats <= 0 ||
39+
amount_sats > 1000000
40+
) {
1541
return NextResponse.json({ error: "Invalid amount (1-1,000,000 sats)" }, { status: 400 });
1642
}
1743

0 commit comments

Comments
 (0)