diff --git a/src/__tests__/walletSessionApi.test.ts b/src/__tests__/walletSessionApi.test.ts new file mode 100644 index 00000000..1c202642 --- /dev/null +++ b/src/__tests__/walletSessionApi.test.ts @@ -0,0 +1,136 @@ +import { beforeAll, beforeEach, describe, expect, it, jest } from "@jest/globals"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { createMockResponse } from "./apiTestUtils"; + +// The handler turned every thrown error into an opaque 500. These tests pin the +// new contract: 400 for bad input, 401 for an invalid OR throwing signature +// check, 200 for a valid one, and 500 reserved for genuine server faults. + +const addCorsHeadersMock = jest.fn<(res: NextApiResponse) => void>(); +const corsMock = jest.fn<(req: NextApiRequest, res: NextApiResponse) => Promise>(); +const checkSignatureMock: jest.Mock = jest.fn(); +const nonceFindFirstMock: jest.Mock = jest.fn(); +const nonceDeleteMock: jest.Mock = jest.fn(); +const getWalletSessionFromReqMock: jest.Mock = jest.fn(); +const setWalletSessionCookieMock = jest.fn(); + +jest.mock("@/lib/cors", () => ({ + __esModule: true, + addCorsCacheBustingHeaders: addCorsHeadersMock, + cors: corsMock, +}), { virtual: true }); + +jest.mock("@/server/db", () => ({ + __esModule: true, + db: { + nonce: { + findFirst: nonceFindFirstMock, + delete: nonceDeleteMock, + }, + }, +}), { virtual: true }); + +// DataSignature is a type-only use; stub the module so it never loads heavy WASM. +jest.mock("@meshsdk/core", () => ({ __esModule: true }), { virtual: true }); + +jest.mock("@meshsdk/core-cst", () => ({ + __esModule: true, + checkSignature: checkSignatureMock, +}), { virtual: true }); + +// Identity normalize keeps these tests focused on status-code behavior; the real +// normalize is exercised by addressCompatibility's own coverage. +jest.mock("@/utils/addressCompatibility", () => ({ + __esModule: true, + normalizeAddressToBech32: (a: string) => a, +}), { virtual: true }); + +jest.mock("@/lib/auth/walletSession", () => ({ + __esModule: true, + getWalletSessionFromReq: getWalletSessionFromReqMock, + setWalletSessionCookie: setWalletSessionCookieMock, +}), { virtual: true }); + +let handler: (req: NextApiRequest, res: NextApiResponse) => Promise; + +const ADDRESS = "addr_test1qpwalletsessionfixture0000000000000000000000000000"; + +const postRequest = (body: unknown): NextApiRequest => + ({ method: "POST", body, cookies: {} } as unknown as NextApiRequest); + +beforeAll(async () => { + process.env.JWT_SECRET = "x".repeat(32); + ({ default: handler } = await import("../pages/api/auth/wallet-session")); +}); + +beforeEach(() => { + jest.clearAllMocks(); + corsMock.mockResolvedValue(undefined); + getWalletSessionFromReqMock.mockReturnValue(null); + (nonceFindFirstMock as any).mockResolvedValue({ id: "nonce-id", value: "deadbeef" }); + (nonceDeleteMock as any).mockResolvedValue({}); + (checkSignatureMock as any).mockResolvedValue(true); +}); + +describe("wallet-session API error handling", () => { + it("returns 400 when address/signature/key are missing", async () => { + const res = createMockResponse(); + await handler(postRequest({ address: ADDRESS }), res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(checkSignatureMock).not.toHaveBeenCalled(); + }); + + it("returns 400 when no nonce has been issued", async () => { + (nonceFindFirstMock as any).mockResolvedValue(null); + const res = createMockResponse(); + await handler(postRequest({ address: ADDRESS, signature: "ab", key: "cd" }), res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: "No nonce issued for this address" }); + }); + + it("returns 401 (NOT 500) when checkSignature THROWS (e.g. hex address / malformed COSE)", async () => { + (checkSignatureMock as any).mockRejectedValue( + new Error('Unknown letter "b". Allowed: qpzry9x8gf2tvdw0s3jn54khce6mua7l'), + ); + const res = createMockResponse(); + await handler(postRequest({ address: ADDRESS, signature: "00", key: "00" }), res); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.status).not.toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ error: "Invalid signature" }); + expect(nonceDeleteMock).not.toHaveBeenCalled(); + }); + + it("returns 401 when checkSignature returns false", async () => { + (checkSignatureMock as any).mockResolvedValue(false); + const res = createMockResponse(); + await handler(postRequest({ address: ADDRESS, signature: "ab", key: "cd" }), res); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ error: "Invalid signature" }); + }); + + it("returns 200 {ok:true} and consumes the nonce for a valid signature", async () => { + const res = createMockResponse(); + await handler(postRequest({ address: ADDRESS, signature: "ab", key: "cd" }), res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ + ok: true, + wallets: [ADDRESS], + primaryWallet: ADDRESS, + }); + expect(nonceDeleteMock).toHaveBeenCalledWith({ where: { id: "nonce-id" } }); + expect(setWalletSessionCookieMock).toHaveBeenCalled(); + }); + + it("returns 500 only for genuine server faults (e.g. DB error)", async () => { + (nonceFindFirstMock as any).mockRejectedValue(new Error("db down")); + const res = createMockResponse(); + await handler(postRequest({ address: ADDRESS, signature: "ab", key: "cd" }), res); + + expect(res.status).toHaveBeenCalledWith(500); + }); +}); diff --git a/src/pages/api/auth/wallet-session.ts b/src/pages/api/auth/wallet-session.ts index f381ebe3..1a90b863 100644 --- a/src/pages/api/auth/wallet-session.ts +++ b/src/pages/api/auth/wallet-session.ts @@ -3,6 +3,7 @@ import { db } from "@/server/db"; import { cors, addCorsCacheBustingHeaders } from "@/lib/cors"; import { DataSignature } from "@meshsdk/core"; import { checkSignature } from "@meshsdk/core-cst"; +import { normalizeAddressToBech32 } from "@/utils/addressCompatibility"; import { getWalletSessionFromReq, setWalletSessionCookie, @@ -22,15 +23,22 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } try { - const { address, signature, key } = req.body ?? {}; + const { address: rawAddress, signature, key } = req.body ?? {}; if ( - typeof address !== "string" || + typeof rawAddress !== "string" || typeof signature !== "string" || typeof key !== "string" ) { return res.status(400).json({ error: "Missing address, signature or key." }); } + // Normalize to bech32 up front (defense in depth). Some CIP-30 wallets hand + // back hex-encoded address bytes; checkSignature() calls Address.fromBech32() + // internally and THROWS on hex ("Unknown letter ..."), not returns false. + // The WalletAuthModal client already normalizes, but other callers may not — + // and an unhandled throw here used to surface as an opaque 500. + const address = normalizeAddressToBech32(rawAddress); + // Fetch the nonce from the database (same table used by /api/v1/getNonce) const nonceEntry = await db.nonce.findFirst({ where: { address } }); if (!nonceEntry) { @@ -40,7 +48,20 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const nonce = nonceEntry.value; const sig: DataSignature = { signature, key }; - const isValid = await checkSignature(nonce, sig, address); + // Verify the signature in isolation. checkSignature can THROW (malformed + // COSE_Sign1 / COSE_Key, non-bech32 address, etc.) as well as return false — + // treat both as an invalid signature (401), never a 500, and log the + // underlying reason so genuine failures stay diagnosable. + let isValid = false; + try { + isValid = await checkSignature(nonce, sig, address); + } catch (verifyError) { + console.warn( + "[api/auth/wallet-session] checkSignature threw; treating as invalid signature:", + verifyError instanceof Error ? verifyError.message : verifyError, + ); + isValid = false; + } if (!isValid) { return res.status(401).json({ error: "Invalid signature" }); @@ -69,8 +90,10 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) primaryWallet: payload.primaryWallet, }); } catch (error) { - console.error("[api/auth/wallet-session] Error:", error); + // Reserved for genuinely unexpected failures (e.g. DB errors). Bad input is + // 400 and a failed/throwing signature check is 401, both handled above — so + // a 500 here now means something actually went wrong server-side. + console.error("[api/auth/wallet-session] Unexpected error:", error); return res.status(500).json({ error: "Internal Server Error" }); } } -