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
17 changes: 17 additions & 0 deletions src/app/api/affiliates/offers/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,23 @@ describe("POST /api/affiliates/offers", () => {
vi.clearAllMocks();
});

it("returns 400 for malformed JSON before touching affiliate offers", async () => {
mockGetAuthContext.mockResolvedValue({ user: { id: "user1" } });

const req = new NextRequest("http://localhost/api/affiliates/offers", {
method: "POST",
body: "{not valid json",
headers: { "Content-Type": "application/json" },
});

const res = await POST(req);
const body = await res.json();

expect(res.status).toBe(400);
expect(body.error).toBe("Invalid JSON body");
expect(mockFrom).not.toHaveBeenCalled();
});

it("rejects javascript: URL in product_url (#18)", async () => {
mockGetAuthContext.mockResolvedValue({ user: { id: "user1" } });

Expand Down
21 changes: 18 additions & 3 deletions src/app/api/affiliates/offers/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { escapePostgrestSearchValue } from "@/lib/security/sanitize";

// eslint-disable-next-line @typescript-eslint/no-explicit-any
type AnySupabase = any;
import { validateOfferInput } from "@/lib/affiliates/validation";
import { validateOfferInput, type OfferInput } from "@/lib/affiliates/validation";

function parsePaginationParam(
value: string | null,
Expand All @@ -28,6 +28,18 @@ function slugify(text: string): string {
return slug || "offer";
}

async function readJsonObject(request: NextRequest) {
try {
const body = await request.json();
if (!body || typeof body !== "object" || Array.isArray(body)) {
return null;
}
return body as Record<string, unknown>;
} catch {
return null;
}
}

/**
* GET /api/affiliates/offers - List affiliate offers (public marketplace)
*/
Expand Down Expand Up @@ -148,8 +160,11 @@ export async function POST(request: NextRequest) {
const rl = checkRateLimit(getRateLimitIdentifier(request, auth.user.id), "write");
if (!rl.allowed) return rateLimitExceeded(rl);

const body = await request.json();
const validation = validateOfferInput(body);
const body = await readJsonObject(request);
if (!body) {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
const validation = validateOfferInput(body as unknown as OfferInput);

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 The double cast body as unknown as OfferInput is necessary because readJsonObject returns Record<string, unknown>, which TypeScript won't accept for OfferInput's required fields. Since validateOfferInput already does full runtime type-narrowing, the cast is safe in practice — but it silently bypasses the compiler. Accepting Record<string, unknown> in validateOfferInput directly (or returning OfferInput | null from a narrowing helper) would keep TypeScript honest here.

Suggested change
const validation = validateOfferInput(body as unknown as OfferInput);
const validation = validateOfferInput(body as OfferInput);

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!


if (!validation.ok) {
return NextResponse.json({ error: validation.errors.join("; ") }, { status: 400 });
Expand Down
Loading