Validate affiliate payout conversion_id before querying conversions#423
Conversation
Greptile SummaryThis PR tightens the
Confidence Score: 4/5Safe to merge; the validation change is correct and targeted with no risk of regressions to the happy path. The implementation change is small, well-scoped, and correct — the stricter guard reliably blocks non-string and blank inputs before any DB call. The only gap is a missing test for the whitespace-only string branch that the route.test.ts would benefit from a whitespace-only test case to fully exercise the new trim-based guard. Important Files Changed
Sequence DiagramsequenceDiagram
participant Client
participant POST Route
participant Supabase
Client->>POST Route: POST /pay { conversion_id: <value> }
POST Route->>Supabase: .from("affiliate_offers").select().eq().single()
Supabase-->>POST Route: offer data
alt not owner
POST Route-->>Client: 403 Not authorized
end
alt "typeof !== "string" OR trimmed === """
POST Route-->>Client: 400 conversion_id must be a non-empty string
Note right of POST Route: affiliate_conversions never queried
end
POST Route->>Supabase: .from("affiliate_conversions").select().eq(conversionId).single()
Supabase-->>POST Route: conversion data
alt not found / already paid / clawed back / not settled / zero commission
POST Route-->>Client: 400/404 error
end
POST Route->>Supabase: internalTransfer (seller to affiliate)
POST Route->>Supabase: .update(status=paid).eq(conversionId)
POST Route->>Supabase: .insert wallet_transactions
POST Route-->>Client: 200 { ok: true, commission_sats }
Reviews (1): Last reviewed commit: "Validate affiliate payout conversion id" | Re-trigger Greptile |
| it("rejects non-string conversion_id before querying conversions (#422)", async () => { | ||
| mockGetAuthContext.mockResolvedValue({ | ||
| user: { id: "seller-1", authMethod: "session" }, | ||
| }); | ||
|
|
||
| mockFrom.mockImplementation((table: string) => { | ||
| if (table === "affiliate_offers") { | ||
| return chainable({ | ||
| id: "offer-1", | ||
| seller_id: "seller-1", | ||
| }); | ||
| } | ||
| return chainable(null); | ||
| }); | ||
|
|
||
| const res = await POST( | ||
| makePostRequest("offer-1", { conversion_id: { id: "conv-1" } }), | ||
| makeParams("offer-1") | ||
| ); | ||
|
|
||
| expect(res.status).toBe(400); | ||
| await expect(res.json()).resolves.toEqual({ | ||
| error: "conversion_id must be a non-empty string", | ||
| }); | ||
| expect(mockFrom).not.toHaveBeenCalledWith("affiliate_conversions"); | ||
| expect(mockGetUserLnWallet).not.toHaveBeenCalled(); | ||
| expect(mockInternalTransfer).not.toHaveBeenCalled(); | ||
| }); |
There was a problem hiding this comment.
The PR description states it rejects both non-string and blank
conversion_id values, but the test only exercises the non-string (object) case. The whitespace-only path (" ") is the one the trim() guard was specifically added for and deserves its own assertion — otherwise a regression that removes the .trim() call would not be caught by this suite.
| it("rejects non-string conversion_id before querying conversions (#422)", async () => { | |
| mockGetAuthContext.mockResolvedValue({ | |
| user: { id: "seller-1", authMethod: "session" }, | |
| }); | |
| mockFrom.mockImplementation((table: string) => { | |
| if (table === "affiliate_offers") { | |
| return chainable({ | |
| id: "offer-1", | |
| seller_id: "seller-1", | |
| }); | |
| } | |
| return chainable(null); | |
| }); | |
| const res = await POST( | |
| makePostRequest("offer-1", { conversion_id: { id: "conv-1" } }), | |
| makeParams("offer-1") | |
| ); | |
| expect(res.status).toBe(400); | |
| await expect(res.json()).resolves.toEqual({ | |
| error: "conversion_id must be a non-empty string", | |
| }); | |
| expect(mockFrom).not.toHaveBeenCalledWith("affiliate_conversions"); | |
| 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" }, | |
| }); | |
| mockFrom.mockImplementation((table: string) => { | |
| if (table === "affiliate_offers") { | |
| return chainable({ | |
| id: "offer-1", | |
| seller_id: "seller-1", | |
| }); | |
| } | |
| return chainable(null); | |
| }); | |
| const res = await POST( | |
| makePostRequest("offer-1", { conversion_id: { id: "conv-1" } }), | |
| makeParams("offer-1") | |
| ); | |
| expect(res.status).toBe(400); | |
| await expect(res.json()).resolves.toEqual({ | |
| error: "conversion_id must be a non-empty string", | |
| }); | |
| expect(mockFrom).not.toHaveBeenCalledWith("affiliate_conversions"); | |
| expect(mockGetUserLnWallet).not.toHaveBeenCalled(); | |
| expect(mockInternalTransfer).not.toHaveBeenCalled(); | |
| }); | |
| it("rejects blank/whitespace-only conversion_id before querying conversions", async () => { | |
| mockGetAuthContext.mockResolvedValue({ | |
| user: { id: "seller-1", authMethod: "session" }, | |
| }); | |
| mockFrom.mockImplementation((table: string) => { | |
| if (table === "affiliate_offers") { | |
| return chainable({ | |
| id: "offer-1", | |
| seller_id: "seller-1", | |
| }); | |
| } | |
| return chainable(null); | |
| }); | |
| const res = await POST( | |
| makePostRequest("offer-1", { conversion_id: " " }), | |
| makeParams("offer-1") | |
| ); | |
| expect(res.status).toBe(400); | |
| await expect(res.json()).resolves.toEqual({ | |
| error: "conversion_id must be a non-empty string", | |
| }); | |
| expect(mockFrom).not.toHaveBeenCalledWith("affiliate_conversions"); | |
| expect(mockGetUserLnWallet).not.toHaveBeenCalled(); | |
| expect(mockInternalTransfer).not.toHaveBeenCalled(); | |
| }); |
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!
Summary
conversion_idvalues in the affiliate conversion payout endpointaffiliate_conversionsFixes #422.
Validation
npm.cmd run test:run -- "src/app/api/affiliates/offers/[id]/conversions/pay/route.test.ts"npm.cmd run type-check