diff --git a/api/routes/__tests__/constants.test.ts b/api/routes/__tests__/constants.test.ts new file mode 100644 index 0000000..bd2c986 --- /dev/null +++ b/api/routes/__tests__/constants.test.ts @@ -0,0 +1,69 @@ +import { afterEach, describe, expect, it, jest } from "@jest/globals"; +import type { NextApiRequest, NextApiResponse } from "next"; +import handler from "../../../pages/api/constants"; + +type MockResponse = NextApiResponse & { + body?: { explorer: { nevm?: string; utxo?: string } }; + statusCode: number; +}; + +const createResponse = (): MockResponse => { + const headers = new Map(); + const response: { + body?: unknown; + statusCode: number; + [key: string]: unknown; + } = { + statusCode: 200, + status(code: number) { + this.statusCode = code; + return this; + }, + json(body: unknown) { + this.body = body; + return this; + }, + end() { + return this; + }, + setHeader(name: string, value: string | string[]) { + headers.set(name, value); + return this; + }, + getHeader(name: string) { + return headers.get(name); + }, + }; + + return response as unknown as MockResponse; +}; + +const originalEnvironment = { ...process.env }; + +afterEach(() => { + process.env = { ...originalEnvironment }; + jest.restoreAllMocks(); +}); + +describe("bridge constants", () => { + it("returns explorer URL bases without trailing slashes", () => { + process.env.NEVM_EXPLORER = "https://explorer.nevm.example///"; + process.env.UTXO_EXPLORER = "https://explorer.utxo.example/"; + const response = createResponse(); + + handler( + { + method: "GET", + headers: {}, + socket: {}, + } as unknown as NextApiRequest, + response + ); + + expect(response.statusCode).toBe(200); + expect(response.body?.explorer).toEqual({ + nevm: "https://explorer.nevm.example", + utxo: "https://explorer.utxo.example", + }); + }); +}); diff --git a/pages/api/constants.ts b/pages/api/constants.ts index 705410c..5e8de69 100644 --- a/pages/api/constants.ts +++ b/pages/api/constants.ts @@ -5,6 +5,7 @@ import { resolveSyscoinIsTestnet, } from "utils/network-config"; import { firstConfiguredUtxoBlockbookUrl } from "utils/syscoin-urls"; +import { stripTrailingSlashes } from "utils/url"; function handler(req: NextApiRequest, res: NextApiResponse) { if ( @@ -47,7 +48,9 @@ function handler(req: NextApiRequest, res: NextApiResponse) { ), }, explorer: { - nevm: process.env.NEVM_EXPLORER, + nevm: process.env.NEVM_EXPLORER + ? stripTrailingSlashes(process.env.NEVM_EXPLORER) + : undefined, utxo: firstConfiguredUtxoBlockbookUrl( process.env.UTXO_EXPLORER, process.env.UTXO_RPC_URL, diff --git a/utils/api-base-url.ts b/utils/api-base-url.ts index 2b2f01f..e3d1b31 100644 --- a/utils/api-base-url.ts +++ b/utils/api-base-url.ts @@ -1,12 +1,5 @@ import { resolveApiProxyTarget } from "./api-proxy-target"; - -const stripTrailingSlashes = (value: string) => { - let end = value.length; - while (end > 0 && value[end - 1] === "/") { - end -= 1; - } - return value.slice(0, end); -}; +import { stripTrailingSlashes } from "./url"; const getApiBaseUrl = () => { if (typeof window !== "undefined") { diff --git a/utils/api/cors.ts b/utils/api/cors.ts index 89a6f2e..70b90b6 100644 --- a/utils/api/cors.ts +++ b/utils/api/cors.ts @@ -1,4 +1,5 @@ import { NextApiRequest, NextApiResponse } from "next"; +import { stripTrailingSlashes } from "../url"; interface ApiCorsOptions { allowCredentials?: boolean; @@ -17,14 +18,6 @@ const DEFAULT_ALLOWED_HEADERS = [ const normalizeHeaderValue = (value?: string | string[]) => Array.isArray(value) ? value[0] : value; -const stripTrailingSlashes = (value: string) => { - let end = value.length; - while (end > 0 && value[end - 1] === "/") { - end -= 1; - } - return value.slice(0, end); -}; - const normalizeOrigin = (origin: string) => stripTrailingSlashes(origin.trim()).toLowerCase(); diff --git a/utils/syscoin-urls.test.ts b/utils/syscoin-urls.test.ts index d85e842..ccab568 100644 --- a/utils/syscoin-urls.test.ts +++ b/utils/syscoin-urls.test.ts @@ -16,12 +16,24 @@ describe("resolveUtxoBlockbookUrl", () => { ); }); + it("maps the legacy mainnet Blockbook host with a trailing slash", () => { + expect(resolveUtxoBlockbookUrl("https://blockbook.syscoin.org/")).toBe( + MAINNET_BLOCKBOOK_URL + ); + }); + it("preserves custom Blockbook URLs", () => { expect(resolveUtxoBlockbookUrl("https://custom-blockbook.example")).toBe( "https://custom-blockbook.example" ); }); + it("removes trailing slashes from custom Blockbook URLs", () => { + expect( + resolveUtxoBlockbookUrl("https://custom-blockbook.example///") + ).toBe("https://custom-blockbook.example"); + }); + it("uses the first configured Blockbook URL", () => { expect( firstConfiguredUtxoBlockbookUrl( diff --git a/utils/syscoin-urls.ts b/utils/syscoin-urls.ts index 9ae389d..d523d7f 100644 --- a/utils/syscoin-urls.ts +++ b/utils/syscoin-urls.ts @@ -1,13 +1,20 @@ +import { stripTrailingSlashes } from "./url"; + const LEGACY_MAINNET_BLOCKBOOK_URL = "https://blockbook.syscoin.org"; export const MAINNET_BLOCKBOOK_URL = "https://explorer-blockbook.syscoin.org"; export const resolveUtxoBlockbookUrl = (url?: string) => { - if (url === LEGACY_MAINNET_BLOCKBOOK_URL) { + if (!url) { + return url; + } + + const normalizedUrl = stripTrailingSlashes(url); + if (normalizedUrl === LEGACY_MAINNET_BLOCKBOOK_URL) { return MAINNET_BLOCKBOOK_URL; } - return url; + return normalizedUrl; }; export const firstConfiguredUtxoBlockbookUrl = ( diff --git a/utils/url.test.ts b/utils/url.test.ts new file mode 100644 index 0000000..03b0b59 --- /dev/null +++ b/utils/url.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "@jest/globals"; +import { stripTrailingSlashes } from "./url"; + +describe("stripTrailingSlashes", () => { + it("preserves a URL without a trailing slash", () => { + expect(stripTrailingSlashes("https://explorer.example")).toBe( + "https://explorer.example" + ); + }); + + it("removes every trailing slash", () => { + expect(stripTrailingSlashes("https://explorer.example///")).toBe( + "https://explorer.example" + ); + }); +}); diff --git a/utils/url.ts b/utils/url.ts new file mode 100644 index 0000000..c79b7c9 --- /dev/null +++ b/utils/url.ts @@ -0,0 +1,7 @@ +export const stripTrailingSlashes = (value: string) => { + let end = value.length; + while (end > 0 && value[end - 1] === "/") { + end -= 1; + } + return value.slice(0, end); +};