diff --git a/src/app/api/affiliates/offers/[id]/route.test.ts b/src/app/api/affiliates/offers/[id]/route.test.ts index ecc76a6a..4e7d2fad 100644 --- a/src/app/api/affiliates/offers/[id]/route.test.ts +++ b/src/app/api/affiliates/offers/[id]/route.test.ts @@ -15,11 +15,7 @@ vi.mock("@/lib/supabase/service", () => ({ }), })); -vi.mock("@/lib/affiliates/validation", () => ({ - validateOfferInput: vi.fn(), -})); - -import { GET } from "./route"; +import { GET, PATCH } from "./route"; function makeRequest(id: string) { return new NextRequest(`http://localhost/api/affiliates/offers/${id}`); @@ -113,3 +109,72 @@ describe("GET /api/affiliates/offers/[id]", () => { expect(res.status).toBe(404); }); }); + +describe("PATCH /api/affiliates/offers/[id]", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetAuthContext.mockResolvedValue({ + user: { id: "seller1", authMethod: "session" }, + }); + }); + + it("rejects non-http product_url updates", async () => { + mockFrom.mockImplementation((table: string) => { + if (table === "affiliate_offers") { + return chainable({ id: "offer-1", seller_id: "seller1" }); + } + return chainable([]); + }); + + const req = new NextRequest("http://localhost/api/affiliates/offers/offer-1", { + method: "PATCH", + body: JSON.stringify({ product_url: "javascript:alert(1)" }), + }); + + const res = await PATCH(req, makeParams("offer-1")); + const body = await res.json(); + + expect(res.status).toBe(400); + expect(body.error).toBe("product_url must use http:// or https:// scheme"); + }); + + it("trims valid product_url updates before saving", async () => { + mockFrom.mockImplementation((table: string) => { + if (table !== "affiliate_offers") return chainable([]); + + const update = vi.fn((data: Record) => ({ + eq: vi.fn(() => ({ + select: vi.fn(() => ({ + single: vi.fn(() => Promise.resolve({ + data: { id: "offer-1", seller_id: "seller1", ...data }, + error: null, + })), + })), + })), + })); + + return { + select: vi.fn(() => ({ + eq: vi.fn(() => ({ + single: vi.fn(() => Promise.resolve({ + data: { id: "offer-1", seller_id: "seller1" }, + error: null, + })), + })), + })), + update, + }; + }); + + const req = new NextRequest("http://localhost/api/affiliates/offers/offer-1", { + method: "PATCH", + body: JSON.stringify({ product_url: " https://example.com/product " }), + }); + + const res = await PATCH(req, makeParams("offer-1")); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.offer.product_url).toBe("https://example.com/product"); + }); +}); diff --git a/src/app/api/affiliates/offers/[id]/route.ts b/src/app/api/affiliates/offers/[id]/route.ts index eec125d1..11db1d43 100644 --- a/src/app/api/affiliates/offers/[id]/route.ts +++ b/src/app/api/affiliates/offers/[id]/route.ts @@ -1,11 +1,9 @@ import { NextRequest, NextResponse } from "next/server"; import { getAuthContext } from "@/lib/auth/get-user"; import { createServiceClient } from "@/lib/supabase/service"; +import { isValidUrl } from "@/lib/affiliates/validation"; -// eslint-disable-next-line @typescript-eslint/no-explicit-any type AnySupabase = any; -import { validateOfferInput } from "@/lib/affiliates/validation"; - /** * GET /api/affiliates/offers/[id] - Get offer details @@ -102,7 +100,25 @@ export async function PATCH( if (body.title !== undefined) updateData.title = body.title.trim(); if (body.description !== undefined) updateData.description = body.description.trim(); - if (body.product_url !== undefined) updateData.product_url = body.product_url; + if (body.product_url !== undefined) { + if (body.product_url === null) { + updateData.product_url = null; + } else if (typeof body.product_url !== "string") { + return NextResponse.json({ error: "product_url must be a string" }, { status: 400 }); + } else { + const productUrl = body.product_url.trim(); + if (productUrl.length === 0) { + updateData.product_url = null; + } else if (!isValidUrl(productUrl)) { + return NextResponse.json( + { error: "product_url must use http:// or https:// scheme" }, + { status: 400 } + ); + } else { + updateData.product_url = productUrl; + } + } + } if (body.product_type !== undefined) updateData.product_type = body.product_type; if (body.price_sats !== undefined) updateData.price_sats = body.price_sats; if (body.commission_rate !== undefined) updateData.commission_rate = body.commission_rate;