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
68 changes: 68 additions & 0 deletions src/app/api/affiliates/settle/route.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {}) {
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,
});
});
});
11 changes: 9 additions & 2 deletions src/app/api/affiliates/settle/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`) {
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 Timing-sensitive bearer token comparison

The check authHeader !== \Bearer ${cronSecret}`uses a non-constant-time string comparison. An attacker who can make many rapid requests and measure response latency can theoretically brute-force the secret one character at a time. In practice this risk is low for a cron endpoint, but for secrets guarding financial settlement operations it is worth switching to a timing-safe compare such as Node'scrypto.timingSafeEqualor thesafe-compare` package to eliminate the side-channel entirely.

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) {
Expand Down
Loading