From 0ff37076413a3b9f5c18aef5b580fbacdedcacbc Mon Sep 17 00:00:00 2001 From: Morgan Penny Date: Thu, 21 May 2026 04:35:26 +0200 Subject: [PATCH] Protect affiliate settlement cron route --- src/app/api/affiliates/settle/route.test.ts | 68 +++++++++++++++++++++ src/app/api/affiliates/settle/route.ts | 11 +++- 2 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 src/app/api/affiliates/settle/route.test.ts diff --git a/src/app/api/affiliates/settle/route.test.ts b/src/app/api/affiliates/settle/route.test.ts new file mode 100644 index 00000000..85ccf6db --- /dev/null +++ b/src/app/api/affiliates/settle/route.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +const mocks = vi.hoisted(() => ({ + admin: {}, + createServiceClient: vi.fn(), + settleCommissions: vi.fn(), +})); + +vi.mock("@/lib/supabase/service", () => ({ + createServiceClient: mocks.createServiceClient, +})); + +vi.mock("@/lib/affiliates/commission", () => ({ + settleCommissions: mocks.settleCommissions, +})); + +import { POST } from "./route"; + +function makeRequest(headers: Record = {}) { + return new NextRequest("http://localhost/api/affiliates/settle", { + method: "POST", + headers, + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv("CRON_SECRET", "test-secret"); + mocks.createServiceClient.mockReturnValue(mocks.admin); + mocks.settleCommissions.mockResolvedValue({ + settled: 2, + failed: 0, + total_sats: 1500, + }); +}); + +describe("POST /api/affiliates/settle", () => { + it("rejects requests when CRON_SECRET is not configured", async () => { + vi.stubEnv("CRON_SECRET", ""); + + const res = await POST(makeRequest({ authorization: "Bearer test-secret" })); + + expect(res.status).toBe(401); + expect(mocks.createServiceClient).not.toHaveBeenCalled(); + expect(mocks.settleCommissions).not.toHaveBeenCalled(); + }); + + it("rejects requests without the bearer cron secret", async () => { + const res = await POST(makeRequest()); + + expect(res.status).toBe(401); + expect(mocks.createServiceClient).not.toHaveBeenCalled(); + expect(mocks.settleCommissions).not.toHaveBeenCalled(); + }); + + it("settles commissions with the configured bearer cron secret", async () => { + const res = await POST(makeRequest({ authorization: "Bearer test-secret" })); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body).toEqual({ settled: 2, failed: 0, total_sats: 1500 }); + expect(mocks.createServiceClient).toHaveBeenCalledOnce(); + expect(mocks.settleCommissions).toHaveBeenCalledWith(mocks.admin, { + limit: 100, + }); + }); +}); diff --git a/src/app/api/affiliates/settle/route.ts b/src/app/api/affiliates/settle/route.ts index d215c654..8c97b5eb 100644 --- a/src/app/api/affiliates/settle/route.ts +++ b/src/app/api/affiliates/settle/route.ts @@ -11,14 +11,21 @@ export async function POST(request: NextRequest) { const cronSecret = process.env.CRON_SECRET; const authHeader = request.headers.get("authorization"); - if (cronSecret && authHeader !== `Bearer ${cronSecret}`) { + if (!cronSecret) { + console.error("[affiliate-settle] CRON_SECRET not set - rejecting request"); + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + if (authHeader !== `Bearer ${cronSecret}`) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } const admin = createServiceClient(); const result = await settleCommissions(admin, { limit: 100 }); - console.log(`[affiliate-settle] settled=${result.settled} failed=${result.failed} total_sats=${result.total_sats}`); + console.log( + `[affiliate-settle] settled=${result.settled} failed=${result.failed} total_sats=${result.total_sats}` + ); return NextResponse.json(result); } catch (err) {