From 779a8f456d651920243be5d1070906dbfb7b74db Mon Sep 17 00:00:00 2001 From: Morgan Penny Date: Thu, 21 May 2026 04:45:05 +0200 Subject: [PATCH 1/2] Handle malformed affiliate conversion bodies --- .../offers/[id]/conversions/route.test.ts | 37 +++++++++++++++++++ .../offers/[id]/conversions/route.ts | 18 +++++++-- 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/src/app/api/affiliates/offers/[id]/conversions/route.test.ts b/src/app/api/affiliates/offers/[id]/conversions/route.test.ts index be671071..5e8787c3 100644 --- a/src/app/api/affiliates/offers/[id]/conversions/route.test.ts +++ b/src/app/api/affiliates/offers/[id]/conversions/route.test.ts @@ -39,6 +39,17 @@ function makePostRequest(id: string, body: Record) { ); } +function makeRawPostRequest(id: string, body: string) { + return new NextRequest( + `http://localhost/api/affiliates/offers/${id}/conversions`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + } + ); +} + function makeParams(id: string) { return { params: Promise.resolve({ id }) }; } @@ -321,4 +332,30 @@ describe("POST /api/affiliates/offers/[id]/conversions", () => { const body2 = await res2.json(); expect(body2.error).toBe("sale_amount_sats must be a positive number"); }); + + it("returns 400 for malformed JSON request bodies", async () => { + mockGetAuthContext.mockResolvedValue({ + user: { id: "user-seller", authMethod: "session" }, + }); + + mockFrom.mockImplementation((table: string) => { + if (table === "affiliate_offers") { + return chainable({ + id: "offer-1", + seller_id: "user-seller", + }); + } + return chainable([]); + }); + + const res = await POST( + makeRawPostRequest("offer-1", "{not valid json"), + makeParams("offer-1") + ); + const body = await res.json(); + + expect(res.status).toBe(400); + expect(body.error).toBe("Invalid request body"); + expect(mockRecordConversion).not.toHaveBeenCalled(); + }); }); diff --git a/src/app/api/affiliates/offers/[id]/conversions/route.ts b/src/app/api/affiliates/offers/[id]/conversions/route.ts index a241607c..3e9a01af 100644 --- a/src/app/api/affiliates/offers/[id]/conversions/route.ts +++ b/src/app/api/affiliates/offers/[id]/conversions/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 { recordConversion } from "@/lib/affiliates/commission"; +import { safeParseBody } from "@/lib/sanitize"; // eslint-disable-next-line @typescript-eslint/no-explicit-any type AnySupabase = any; @@ -131,7 +132,15 @@ export async function POST( return NextResponse.json({ error: "Not authorized" }, { status: 403 }); } - const body = await request.json(); + const body = await safeParseBody<{ + affiliate_id?: unknown; + sale_amount_sats?: unknown; + note?: unknown; + }>(request); + if (!body) { + return NextResponse.json({ error: "Invalid request body" }, { status: 400 }); + } + const { affiliate_id, sale_amount_sats, note } = body; if (!affiliate_id || typeof affiliate_id !== "string") { @@ -177,8 +186,9 @@ export async function POST( // Update source and note on the created conversion const updateData: Record = { source: "manual" }; - if (note && typeof note === "string") { - updateData.note = note.trim(); + const noteText = typeof note === "string" ? note.trim() : null; + if (noteText) { + updateData.note = noteText; } await (admin as AnySupabase) @@ -192,7 +202,7 @@ export async function POST( commission_sats: result.commission_sats, settles_at: result.settles_at, source: "manual", - note: note?.trim() || null, + note: noteText, }, }); } catch { From 2a4ee9eb181dab3eea95d079874b67122a6bcf4f Mon Sep 17 00:00:00 2001 From: Morgan Penny Date: Sat, 23 May 2026 13:58:38 +0200 Subject: [PATCH 2/2] Handle malformed conversion update JSON --- .../offers/[id]/conversions/route.test.ts | 80 ++++++++++++++++++- .../offers/[id]/conversions/route.ts | 15 +++- 2 files changed, 92 insertions(+), 3 deletions(-) diff --git a/src/app/api/affiliates/offers/[id]/conversions/route.test.ts b/src/app/api/affiliates/offers/[id]/conversions/route.test.ts index 5e8787c3..5e33246b 100644 --- a/src/app/api/affiliates/offers/[id]/conversions/route.test.ts +++ b/src/app/api/affiliates/offers/[id]/conversions/route.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { GET, POST } from "./route"; +import { DELETE, GET, POST, PUT } from "./route"; import { NextRequest } from "next/server"; // Mock auth @@ -50,6 +50,17 @@ function makeRawPostRequest(id: string, body: string) { ); } +function makeRawRequest(id: string, method: "PUT" | "DELETE", body: string) { + return new NextRequest( + `http://localhost/api/affiliates/offers/${id}/conversions`, + { + method, + headers: { "Content-Type": "application/json" }, + body, + } + ); +} + function makeParams(id: string) { return { params: Promise.resolve({ id }) }; } @@ -359,3 +370,70 @@ describe("POST /api/affiliates/offers/[id]/conversions", () => { expect(mockRecordConversion).not.toHaveBeenCalled(); }); }); + +describe("PUT /api/affiliates/offers/[id]/conversions", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 400 for malformed JSON request bodies", async () => { + mockGetAuthContext.mockResolvedValue({ + user: { id: "user-seller", authMethod: "session" }, + }); + + mockFrom.mockImplementation((table: string) => { + if (table === "affiliate_offers") { + return chainable({ + id: "offer-1", + seller_id: "user-seller", + commission_rate: 0.2, + commission_type: "percentage", + commission_flat_sats: 0, + }); + } + throw new Error(`Unexpected table query: ${table}`); + }); + + const res = await PUT( + makeRawRequest("offer-1", "PUT", "{not valid json"), + makeParams("offer-1") + ); + const body = await res.json(); + + expect(res.status).toBe(400); + expect(body.error).toBe("Invalid request body"); + expect(mockFrom).not.toHaveBeenCalledWith("affiliate_conversions"); + }); +}); + +describe("DELETE /api/affiliates/offers/[id]/conversions", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 400 for malformed JSON request bodies", async () => { + mockGetAuthContext.mockResolvedValue({ + user: { id: "user-seller", authMethod: "session" }, + }); + + mockFrom.mockImplementation((table: string) => { + if (table === "affiliate_offers") { + return chainable({ + id: "offer-1", + seller_id: "user-seller", + }); + } + throw new Error(`Unexpected table query: ${table}`); + }); + + const res = await DELETE( + makeRawRequest("offer-1", "DELETE", "{not valid json"), + makeParams("offer-1") + ); + const body = await res.json(); + + expect(res.status).toBe(400); + expect(body.error).toBe("Invalid request body"); + expect(mockFrom).not.toHaveBeenCalledWith("affiliate_conversions"); + }); +}); diff --git a/src/app/api/affiliates/offers/[id]/conversions/route.ts b/src/app/api/affiliates/offers/[id]/conversions/route.ts index 3e9a01af..287b0a18 100644 --- a/src/app/api/affiliates/offers/[id]/conversions/route.ts +++ b/src/app/api/affiliates/offers/[id]/conversions/route.ts @@ -240,7 +240,15 @@ export async function PUT( return NextResponse.json({ error: "Not authorized" }, { status: 403 }); } - const body = await request.json(); + const body = await safeParseBody<{ + conversion_id?: unknown; + sale_amount_sats?: unknown; + note?: unknown; + status?: unknown; + }>(request); + if (!body) { + return NextResponse.json({ error: "Invalid request body" }, { status: 400 }); + } const { conversion_id, sale_amount_sats, note, status } = body; if (!conversion_id) { @@ -305,7 +313,10 @@ export async function DELETE( return NextResponse.json({ error: "Not authorized" }, { status: 403 }); } - const body = await request.json(); + const body = await safeParseBody<{ conversion_id?: unknown }>(request); + if (!body) { + return NextResponse.json({ error: "Invalid request body" }, { status: 400 }); + } const { conversion_id } = body; if (!conversion_id) {