diff --git a/package-lock.json b/package-lock.json index e8350fb7..6e970f33 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "plotlink", - "version": "1.33.0", + "version": "1.33.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "plotlink", - "version": "1.33.0", + "version": "1.33.1", "workspaces": [ "packages/*" ], diff --git a/package.json b/package.json index 96d16ffb..7bb61bc4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "plotlink", - "version": "1.33.0", + "version": "1.33.1", "private": true, "workspaces": [ "packages/*" diff --git a/src/app/api/airdrop/referral-code/route.test.ts b/src/app/api/airdrop/referral-code/route.test.ts new file mode 100644 index 00000000..66266e38 --- /dev/null +++ b/src/app/api/airdrop/referral-code/route.test.ts @@ -0,0 +1,93 @@ +// @vitest-environment node +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const mockInsert = vi.fn().mockReturnValue({ error: null }); +const mockSelectCode = vi.fn(); + +vi.mock("../../../../../lib/airdrop/siwe-verify", () => ({ + verifySiweRequest: vi.fn(), +})); +vi.mock("../../../../../lib/rate-limit", () => ({ + checkRateLimit: () => Promise.resolve(true), + getClientIp: () => "127.0.0.1", +})); +vi.mock("../../../../../lib/supabase", () => ({ + createServerClient: () => ({ + from: (table: string) => { + if (table === "pl_referral_codes") { + return { + select: () => ({ eq: () => ({ single: mockSelectCode }) }), + insert: mockInsert, + }; + } + return {}; + }, + }), +})); +vi.mock("nanoid", () => ({ nanoid: () => "testcode" })); + +import { GET, POST } from "./route"; +import { verifySiweRequest } from "../../../../../lib/airdrop/siwe-verify"; +import { NextRequest } from "next/server"; + +function makePostReq(body: unknown) { + return new Request("http://localhost/api/airdrop/referral-code", { + method: "POST", + body: JSON.stringify(body), + headers: { "content-type": "application/json" }, + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + mockInsert.mockReturnValue({ error: null }); +}); + +describe("POST /api/airdrop/referral-code", () => { + it("returns 401 on expired SIWE signature", async () => { + vi.mocked(verifySiweRequest).mockResolvedValue({ ok: false, error: "expired" }); + const res = await POST(makePostReq({ message: "m", signature: "s" })); + expect(res.status).toBe(401); + expect(mockInsert).not.toHaveBeenCalled(); + }); + + it("returns 401 on wrong chainId", async () => { + vi.mocked(verifySiweRequest).mockResolvedValue({ ok: false, error: "chain_id_mismatch" }); + const res = await POST(makePostReq({ message: "m", signature: "s" })); + expect(res.status).toBe(401); + expect(mockInsert).not.toHaveBeenCalled(); + }); + + it("succeeds with valid SIWE and generates code", async () => { + vi.mocked(verifySiweRequest).mockResolvedValue({ ok: true, address: "0xabc" }); + mockSelectCode.mockResolvedValue({ data: null }); + + const res = await POST(makePostReq({ message: "m", signature: "s" })); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.code).toBe("testcode"); + }); +}); + +describe("GET /api/airdrop/referral-code", () => { + it("remains unauthenticated — returns code without auth headers", async () => { + mockSelectCode.mockResolvedValue({ data: { code: "mycode", is_farcaster_username: false } }); + const req = new NextRequest("http://localhost/api/airdrop/referral-code?address=0xabc"); + + const res = await GET(req); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.code).toBe("mycode"); + expect(data).toHaveProperty("is_farcaster_username"); + }); + + it("returns { code: null } for unknown address", async () => { + mockSelectCode.mockResolvedValue({ data: null }); + const req = new NextRequest("http://localhost/api/airdrop/referral-code?address=0xnone"); + + const res = await GET(req); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.code).toBeNull(); + }); +}); diff --git a/src/app/api/airdrop/referral-code/route.ts b/src/app/api/airdrop/referral-code/route.ts index 9689a4da..da1f580f 100644 --- a/src/app/api/airdrop/referral-code/route.ts +++ b/src/app/api/airdrop/referral-code/route.ts @@ -9,7 +9,7 @@ import { NextResponse, type NextRequest } from "next/server"; import { nanoid } from "nanoid"; import { createServerClient } from "../../../../../lib/supabase"; -import { verifyWalletOwnership } from "../../../../../lib/airdrop/verify-wallet"; +import { verifySiweRequest } from "../../../../../lib/airdrop/siwe-verify"; import { checkRateLimit, getClientIp } from "../../../../../lib/rate-limit"; export async function GET(req: NextRequest) { @@ -48,7 +48,7 @@ export async function POST(req: Request) { } let message: string; - let signature: `0x${string}`; + let signature: string; let useFarcasterUsername: boolean; try { const body = await req.json(); @@ -60,10 +60,11 @@ export async function POST(req: Request) { return NextResponse.json({ error: "Invalid request body" }, { status: 400 }); } - const address = await verifyWalletOwnership(message, signature); - if (!address) { - return NextResponse.json({ error: "Invalid signature" }, { status: 401 }); + const auth = await verifySiweRequest(message, signature); + if (!auth.ok) { + return NextResponse.json({ error: auth.error }, { status: 401 }); } + const address = auth.address; // Check for existing code (immutable once set) const { data: existing } = await supabase diff --git a/src/app/api/airdrop/register-referral/route.test.ts b/src/app/api/airdrop/register-referral/route.test.ts new file mode 100644 index 00000000..3351c217 --- /dev/null +++ b/src/app/api/airdrop/register-referral/route.test.ts @@ -0,0 +1,73 @@ +// @vitest-environment node +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const mockInsert = vi.fn().mockReturnValue({ error: null }); +const mockSelectReferred = vi.fn(); +const mockSelectCode = vi.fn(); + +vi.mock("../../../../../lib/airdrop/siwe-verify", () => ({ + verifySiweRequest: vi.fn(), +})); +vi.mock("../../../../../lib/rate-limit", () => ({ + checkRateLimit: () => Promise.resolve(true), + getClientIp: () => "127.0.0.1", +})); +vi.mock("../../../../../lib/supabase", () => ({ + createServerClient: () => ({ + from: (table: string) => { + if (table === "pl_referrals") { + return { + select: () => ({ eq: () => ({ single: mockSelectReferred }) }), + insert: mockInsert, + }; + } + if (table === "pl_referral_codes") { + return { select: () => ({ eq: () => ({ single: mockSelectCode }) }) }; + } + return {}; + }, + }), +})); + +import { POST } from "./route"; +import { verifySiweRequest } from "../../../../../lib/airdrop/siwe-verify"; + +function makeReq(body: unknown) { + return new Request("http://localhost/api/airdrop/register-referral", { + method: "POST", + body: JSON.stringify(body), + headers: { "content-type": "application/json" }, + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + mockInsert.mockReturnValue({ error: null }); +}); + +describe("POST /api/airdrop/register-referral", () => { + it("returns 401 on expired SIWE signature", async () => { + vi.mocked(verifySiweRequest).mockResolvedValue({ ok: false, error: "expired" }); + const res = await POST(makeReq({ message: "m", signature: "s", referralCode: "abc" })); + expect(res.status).toBe(401); + expect(mockInsert).not.toHaveBeenCalled(); + }); + + it("returns 401 on wrong domain", async () => { + vi.mocked(verifySiweRequest).mockResolvedValue({ ok: false, error: "domain_mismatch" }); + const res = await POST(makeReq({ message: "m", signature: "s", referralCode: "abc" })); + expect(res.status).toBe(401); + expect(mockInsert).not.toHaveBeenCalled(); + }); + + it("succeeds with valid SIWE signature", async () => { + vi.mocked(verifySiweRequest).mockResolvedValue({ ok: true, address: "0xabc" }); + mockSelectReferred.mockResolvedValue({ data: null }); + mockSelectCode.mockResolvedValue({ data: { address: "0xref" } }); + + const res = await POST(makeReq({ message: "m", signature: "s", referralCode: "code1" })); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.success).toBe(true); + }); +}); diff --git a/src/app/api/airdrop/register-referral/route.ts b/src/app/api/airdrop/register-referral/route.ts index 2b6e486f..5c537808 100644 --- a/src/app/api/airdrop/register-referral/route.ts +++ b/src/app/api/airdrop/register-referral/route.ts @@ -11,7 +11,7 @@ import { NextResponse, type NextRequest } from "next/server"; import { createServerClient } from "../../../../../lib/supabase"; -import { verifyWalletOwnership } from "../../../../../lib/airdrop/verify-wallet"; +import { verifySiweRequest } from "../../../../../lib/airdrop/siwe-verify"; import { checkRateLimit, getClientIp } from "../../../../../lib/rate-limit"; export async function GET(req: NextRequest) { @@ -64,7 +64,7 @@ export async function POST(req: Request) { } let message: string; - let signature: `0x${string}`; + let signature: string; let referralCode: string; try { const body = await req.json(); @@ -76,10 +76,11 @@ export async function POST(req: Request) { return NextResponse.json({ error: "Invalid request body" }, { status: 400 }); } - const address = await verifyWalletOwnership(message, signature); - if (!address) { - return NextResponse.json({ error: "Invalid signature" }, { status: 401 }); + const auth = await verifySiweRequest(message, signature); + if (!auth.ok) { + return NextResponse.json({ error: auth.error }, { status: 401 }); } + const address = auth.address; // Check if already referred const { data: existing } = await supabase