Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions src/app/api/affiliates/offers/[id]/conversions/pay/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,17 @@ function makePostRequest(id: string, body: Record<string, unknown>) {
);
}

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 }) };
}
Expand All @@ -54,6 +65,25 @@ describe("POST /api/affiliates/offers/[id]/conversions/pay", () => {
vi.clearAllMocks();
});

it("rejects malformed JSON before querying the affiliate offer", async () => {
mockGetAuthContext.mockResolvedValue({
user: { id: "seller-1", authMethod: "session" },
});

const res = await POST(
makeRawPostRequest("offer-1", "{not valid json"),
makeParams("offer-1")
);

expect(res.status).toBe(400);
await expect(res.json()).resolves.toEqual({
error: "Invalid JSON body",
});
expect(mockFrom).not.toHaveBeenCalled();
expect(mockGetUserLnWallet).not.toHaveBeenCalled();
expect(mockInternalTransfer).not.toHaveBeenCalled();
});

it("rejects non-string conversion_id before querying conversions (#422)", async () => {
mockGetAuthContext.mockResolvedValue({
user: { id: "seller-1", authMethod: "session" },
Expand Down
30 changes: 19 additions & 11 deletions src/app/api/affiliates/offers/[id]/conversions/pay/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,25 @@ export async function POST(
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
const conversion_id =
body && typeof body === "object" && !Array.isArray(body)
? (body as Record<string, unknown>).conversion_id
: undefined;

if (typeof conversion_id !== "string" || conversion_id.trim() === "") {
return NextResponse.json(
{ error: "conversion_id must be a non-empty string" },
{ status: 400 }
);
}
const conversionId = conversion_id.trim();
Comment on lines +24 to +41

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Authorization check now runs after input validation

Moving body parsing and conversion_id validation above the ownership check changes the observable behavior for non-owners. Previously any authenticated user who didn't own the offer received 403 regardless of body content. Now they receive 400 for malformed JSON or an invalid conversion_id, and only reach 403 when the JSON is well-formed with a string conversion_id. The conventional order (authenticate → authorize → validate) avoids leaking API contract details to callers who have no business touching the resource. The fix is still correct for the stated goal, but the ordering could be tightened by moving body parsing after the offer.seller_id ownership gate — or at minimum noting the trade-off.


const admin = createServiceClient();

// Verify seller ownership
Expand All @@ -34,17 +53,6 @@ export async function POST(
return NextResponse.json({ error: "Not authorized" }, { status: 403 });
}

const body = await request.json();
const { conversion_id } = body;

if (typeof conversion_id !== "string" || conversion_id.trim() === "") {
return NextResponse.json(
{ error: "conversion_id must be a non-empty string" },
{ status: 400 }
);
}
const conversionId = conversion_id.trim();

// Get the conversion
const { data: conv } = await (admin as AnySupabase)
.from("affiliate_conversions")
Expand Down
Loading