diff --git a/src/app/api/affiliates/offers/[id]/conversions/pay/route.test.ts b/src/app/api/affiliates/offers/[id]/conversions/pay/route.test.ts new file mode 100644 index 00000000..cbe89fbe --- /dev/null +++ b/src/app/api/affiliates/offers/[id]/conversions/pay/route.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +const mockGetAuthContext = vi.fn(); +vi.mock("@/lib/auth/get-user", () => ({ + getAuthContext: (...args: unknown[]) => mockGetAuthContext(...args), +})); + +const mockFrom = vi.fn(); +vi.mock("@/lib/supabase/service", () => ({ + createServiceClient: () => ({ + from: (...args: unknown[]) => mockFrom(...args), + }), +})); + +const mockGetUserLnWallet = vi.fn(); +const mockInternalTransfer = vi.fn(); +vi.mock("@/lib/lightning/wallet-utils", () => ({ + getUserLnWallet: (...args: unknown[]) => mockGetUserLnWallet(...args), + internalTransfer: (...args: unknown[]) => mockInternalTransfer(...args), +})); + +import { POST } from "./route"; + +function makeRawPostRequest(id: string, body: string) { + return new NextRequest( + `http://localhost/api/affiliates/offers/${id}/conversions/pay`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + } + ); +} + +function makeParams(id: string) { + return { params: Promise.resolve({ id }) }; +} + +function makeOfferQuery(sellerId: string) { + const query = { + select: vi.fn(() => query), + eq: vi.fn(() => query), + single: vi.fn().mockResolvedValue({ + data: { id: "offer-1", seller_id: sellerId }, + error: null, + }), + }; + return query; +} + +describe("POST /api/affiliates/offers/[id]/conversions/pay", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 400 for malformed JSON before running payout logic", async () => { + mockGetAuthContext.mockResolvedValue({ + user: { id: "seller-1", authMethod: "session" }, + }); + + mockFrom.mockImplementation((table: string) => { + if (table === "affiliate_offers") { + return makeOfferQuery("seller-1"); + } + throw new Error(`Unexpected table query: ${table}`); + }); + + const res = await POST(makeRawPostRequest("offer-1", "{"), makeParams("offer-1")); + + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toBe("conversion_id is required"); + expect(mockFrom).not.toHaveBeenCalledWith("affiliate_conversions"); + expect(mockGetUserLnWallet).not.toHaveBeenCalled(); + expect(mockInternalTransfer).not.toHaveBeenCalled(); + }); + + it("returns 400 for non-string conversion IDs before querying conversions", async () => { + mockGetAuthContext.mockResolvedValue({ + user: { id: "seller-1", authMethod: "session" }, + }); + + mockFrom.mockImplementation((table: string) => { + if (table === "affiliate_offers") { + return makeOfferQuery("seller-1"); + } + throw new Error(`Unexpected table query: ${table}`); + }); + + const res = await POST( + makeRawPostRequest("offer-1", JSON.stringify({ conversion_id: { id: "conv-1" } })), + makeParams("offer-1") + ); + + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toBe("conversion_id is required"); + expect(mockFrom).not.toHaveBeenCalledWith("affiliate_conversions"); + expect(mockGetUserLnWallet).not.toHaveBeenCalled(); + expect(mockInternalTransfer).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/affiliates/offers/[id]/conversions/pay/route.ts b/src/app/api/affiliates/offers/[id]/conversions/pay/route.ts index a299dd4e..23989f92 100644 --- a/src/app/api/affiliates/offers/[id]/conversions/pay/route.ts +++ b/src/app/api/affiliates/offers/[id]/conversions/pay/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getAuthContext } from "@/lib/auth/get-user"; import { createServiceClient } from "@/lib/supabase/service"; import { getUserLnWallet, internalTransfer } from "@/lib/lightning/wallet-utils"; +import { safeParseBody } from "@/lib/sanitize"; // eslint-disable-next-line @typescript-eslint/no-explicit-any type AnySupabase = any; @@ -35,10 +36,10 @@ export async function POST( return NextResponse.json({ error: "Not authorized" }, { status: 403 }); } - const body = await request.json(); - const { conversion_id } = body; + const body = await safeParseBody<{ conversion_id?: unknown }>(request); + const conversion_id = body?.conversion_id; - if (!conversion_id) { + if (typeof conversion_id !== "string" || !conversion_id.trim()) { return NextResponse.json({ error: "conversion_id is required" }, { status: 400 }); } @@ -46,7 +47,7 @@ export async function POST( const { data: conv } = await (admin as AnySupabase) .from("affiliate_conversions") .select("id, affiliate_id, commission_sats, status") - .eq("id", conversion_id) + .eq("id", conversion_id.trim()) .eq("offer_id", id) .single();