Skip to content
Open
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
136 changes: 136 additions & 0 deletions src/__tests__/walletSessionApi.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>>();
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<unknown>;

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);
});
});
33 changes: 28 additions & 5 deletions src/pages/api/auth/wallet-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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) {
Expand All @@ -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" });
Expand Down Expand Up @@ -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" });
}
}

Loading