diff --git a/packages/common/base/src/apiClient/ApiClient.test.ts b/packages/common/base/src/apiClient/ApiClient.test.ts new file mode 100644 index 000000000..87cefa734 --- /dev/null +++ b/packages/common/base/src/apiClient/ApiClient.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, test, vi, beforeEach } from "vitest"; +import { ApiClient } from "./ApiClient"; + +// Mock fetch globally +const mockFetch = vi.fn(); +global.fetch = mockFetch; + +class TestApiClient extends ApiClient { + get authHeaders(): HeadersInit { + return { "x-api-key": "test-key" }; + } + + get baseUrl(): string { + return "https://api.test.com"; + } +} + +describe("ApiClient", () => { + let client: TestApiClient; + + beforeEach(() => { + client = new TestApiClient(); + mockFetch.mockClear(); + }); + + describe("buildUrl", () => { + test("should correctly build URL with normalized paths", () => { + const url = client.buildUrl("/test/path"); + expect(url).toBe("https://api.test.com/test/path"); + }); + + test("should handle paths with leading and trailing slashes", () => { + const url = client.buildUrl("/test/path/"); + expect(url).toBe("https://api.test.com/test/path"); + }); + + test("should handle empty path", () => { + const url = client.buildUrl(""); + expect(url).toBe("https://api.test.com/"); + }); + }); + + describe("HTTP methods", () => { + const testPath = "/test"; + const testParams = { + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ test: "data" }), + }; + + test("should make GET request with correct parameters", async () => { + mockFetch.mockResolvedValueOnce(new Response()); + await client.get(testPath, testParams); + + expect(mockFetch).toHaveBeenCalledWith( + "https://api.test.com/test", + expect.objectContaining({ + method: "GET", + headers: expect.objectContaining({ + "x-api-key": "test-key", + "Content-Type": "application/json", + }), + body: JSON.stringify({ test: "data" }), + }) + ); + }); + + test("should make POST request with correct parameters", async () => { + mockFetch.mockResolvedValueOnce(new Response()); + await client.post(testPath, testParams); + + expect(mockFetch).toHaveBeenCalledWith( + "https://api.test.com/test", + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ + "x-api-key": "test-key", + "Content-Type": "application/json", + }), + body: JSON.stringify({ test: "data" }), + }) + ); + }); + + test("should make PUT request with correct parameters", async () => { + mockFetch.mockResolvedValueOnce(new Response()); + await client.put(testPath, testParams); + + expect(mockFetch).toHaveBeenCalledWith( + "https://api.test.com/test", + expect.objectContaining({ + method: "PUT", + headers: expect.objectContaining({ + "x-api-key": "test-key", + "Content-Type": "application/json", + }), + body: JSON.stringify({ test: "data" }), + }) + ); + }); + + test("should make DELETE request with correct parameters", async () => { + mockFetch.mockResolvedValueOnce(new Response()); + await client.delete(testPath, testParams); + + expect(mockFetch).toHaveBeenCalledWith( + "https://api.test.com/test", + expect.objectContaining({ + method: "DELETE", + headers: expect.objectContaining({ + "x-api-key": "test-key", + "Content-Type": "application/json", + }), + body: JSON.stringify({ test: "data" }), + }) + ); + }); + + test("should make PATCH request with correct parameters", async () => { + mockFetch.mockResolvedValueOnce(new Response()); + await client.patch(testPath, testParams); + + expect(mockFetch).toHaveBeenCalledWith( + "https://api.test.com/test", + expect.objectContaining({ + method: "PATCH", + headers: expect.objectContaining({ + "x-api-key": "test-key", + "Content-Type": "application/json", + }), + body: JSON.stringify({ test: "data" }), + }) + ); + }); + }); +}); \ No newline at end of file diff --git a/packages/common/base/src/apiClient/CrossmintApiClient.test.ts b/packages/common/base/src/apiClient/CrossmintApiClient.test.ts new file mode 100644 index 000000000..8676c4d0a --- /dev/null +++ b/packages/common/base/src/apiClient/CrossmintApiClient.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, test } from "vitest"; +import { CrossmintApiClient } from "./CrossmintApiClient"; +import { environmentToCrossmintBaseURL } from "../apiKey/utils/environmentToCrossmintBaseURL"; + +const VALID_API_KEY = + "ck_development_A61UZQnvjSQcM5qVBaBactgqebxafWAVsNdD2xLkgBxoYuH5q2guM8r9DUmZQzE1WYyoByGVYpEG2o9gVSzAZFsrLbfKGERUJ6D5CW6S9AsJGAc3ctgrsD4n2ioekzGj7KPbLwT3SysDjMamYXLxEroUbQSdwf6aLF4zeEpECq2crkTUQeLFzxzmjWNxFDHFYefDrfrFPCURvBXJLf5pCxCQ"; + +describe("CrossmintApiClient", () => { + const internalConfig = { + sdkMetadata: { + name: "test-sdk", + version: "1.0.0", + }, + }; + + test("should throw error when API key is invalid", () => { + expect(() => { + new CrossmintApiClient( + { apiKey: "invalid-key" }, + { internalConfig } + ); + }).toThrow("Malformed API key. Must start with 'ck' or 'sk'."); + }); + + test("should throw error when API key has wrong environment", () => { + expect(() => { + new CrossmintApiClient( + { apiKey: VALID_API_KEY }, + { + internalConfig: { + ...internalConfig, + apiKeyExpectations: { environment: "production" }, + }, + } + ); + }).toThrow("Disallowed API key. You passed a development API key, but a production API key is required."); + }); + + test("should throw error when API key has wrong usage origin", () => { + expect(() => { + new CrossmintApiClient( + { apiKey: VALID_API_KEY }, + { + internalConfig: { + ...internalConfig, + apiKeyExpectations: { usageOrigin: "server" }, + }, + } + ); + }).toThrow("Disallowed API key. You passed a client API key, but a server API key is required."); + }); + + test("should initialize with valid API key", () => { + const client = new CrossmintApiClient( + { apiKey: VALID_API_KEY }, + { internalConfig } + ); + + expect(client.environment).toBe("development"); + expect(client.baseUrl).toBe(environmentToCrossmintBaseURL("development")); + expect(client.authHeaders).toEqual({ + "x-api-key": VALID_API_KEY, + }); + }); + + test("should use override base URL when provided", () => { + const overrideUrl = "https://custom-api.example.com"; + const client = new CrossmintApiClient( + { apiKey: VALID_API_KEY, overrideBaseUrl: overrideUrl }, + { internalConfig } + ); + + expect(client.baseUrl).toBe(overrideUrl); + }); + + test("should include JWT in auth headers when provided", () => { + const jwt = "test.jwt.token"; + const client = new CrossmintApiClient( + { apiKey: VALID_API_KEY, jwt }, + { internalConfig } + ); + + expect(client.authHeaders).toEqual({ + "x-api-key": VALID_API_KEY, + Authorization: `Bearer ${jwt}`, + }); + }); + + test("should handle different environments correctly", () => { + const environments = ["development", "staging", "production"] as const; + + for (const env of environments) { + expect(() => { + new CrossmintApiClient( + { apiKey: `ck_${env}_5KtPn3` }, + { internalConfig } + ); + }).toThrow("Invalid API key. Failed to validate signature"); + } + }); +}); \ No newline at end of file diff --git a/packages/common/base/src/apiKey/validateAPIKey.test.ts b/packages/common/base/src/apiKey/validateAPIKey.test.ts index 16eedfe33..03a3a0f9e 100644 --- a/packages/common/base/src/apiKey/validateAPIKey.test.ts +++ b/packages/common/base/src/apiKey/validateAPIKey.test.ts @@ -16,6 +16,7 @@ describe("validateAPIKey", () => { } expect(result.isValid).toBe(false); + expect(result.message).toBe("Malformed API key. Must start with 'ck' or 'sk'."); }); test("Should disallow when signature is invalid", () => { @@ -46,4 +47,57 @@ describe("validateAPIKey", () => { expect(result.environment).toBe("development"); expect(result.prefix).toBe("ck_development"); }); + + // Additional edge cases + test("Should disallow empty API key", () => { + const result = validateAPIKey(""); + expect(result.isValid).toBe(false); + if (!result.isValid) { + expect(result.message).toBe("Malformed API key. Must start with 'ck' or 'sk'."); + } + }); + + test("Should disallow API key with invalid base58 encoding", () => { + const result = validateAPIKey("ck_development_5KtPn3"); + expect(result.isValid).toBe(false); + if (!result.isValid) { + expect(result.message).toBe("Invalid API key. Failed to validate signature"); + } + }); + + test("Should disallow API key with malformed data format", () => { + const malformedData = base58.encode(new TextEncoder().encode("invalid_format")); + const result = validateAPIKey(`ck_development_${malformedData}`); + expect(result.isValid).toBe(false); + if (!result.isValid) { + expect(result.message).toBe("Invalid API key. Failed to validate signature"); + } + }); + + test("Should disallow API key with missing project ID", () => { + const malformedData = base58.encode(new TextEncoder().encode(":5gt3DJTWBAw1AjL5pHo6z6NunHZNJqj15iEAveVN5CBUSqBB94Hetn9paFpx9zLFreQGAgy1TkDQaWSUXFMXjgvU")); + const result = validateAPIKey(`ck_development_${malformedData}`); + expect(result.isValid).toBe(false); + if (!result.isValid) { + expect(result.message).toBe("Invalid API key. Failed to validate signature"); + } + }); + + test("Should disallow API key with invalid project ID format", () => { + const malformedData = base58.encode(new TextEncoder().encode("not-a-uuid:5gt3DJTWBAw1AjL5pHo6z6NunHZNJqj15iEAveVN5CBUSqBB94Hetn9paFpx9zLFreQGAgy1TkDQaWSUXFMXjgvU")); + const result = validateAPIKey(`ck_development_${malformedData}`); + expect(result.isValid).toBe(false); + if (!result.isValid) { + expect(result.message).toBe("Invalid API key. Failed to validate signature"); + } + }); + + test("Should handle API key with maximum length", () => { + const longData = "a".repeat(1000); + const result = validateAPIKey(`ck_development_${base58.encode(new TextEncoder().encode(longData))}`); + expect(result.isValid).toBe(false); + if (!result.isValid) { + expect(result.message).toBe("Invalid API key. Failed to validate signature"); + } + }); }); diff --git a/packages/common/base/src/blockchain/utils/blockchainUtils.test.ts b/packages/common/base/src/blockchain/utils/blockchainUtils.test.ts new file mode 100644 index 000000000..a1ead260d --- /dev/null +++ b/packages/common/base/src/blockchain/utils/blockchainUtils.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, test } from "vitest"; +import { blockchainToDisplayName } from "./blockchainToCopyName"; +import { isBlockchain } from "./isBlockchain"; +import { isBlockchainIncludingTestnets } from "./isBlockchainIncludingTestnets"; +import { BLOCKCHAINS, BLOCKCHAINS_INCLUDING_TESTNETS, type Blockchain, type BlockchainIncludingTestnet } from "../types"; + +describe("blockchainUtils", () => { + describe("blockchainToDisplayName", () => { + test("should return correct display name for mainnet blockchains", () => { + expect(blockchainToDisplayName("ethereum")).toBe("Ethereum"); + expect(blockchainToDisplayName("solana")).toBe("Solana"); + expect(blockchainToDisplayName("polygon")).toBe("Polygon"); + }); + + test("should return correct display name for testnet blockchains", () => { + expect(blockchainToDisplayName("ethereum-sepolia")).toBe("Ethereum Sepolia"); + expect(blockchainToDisplayName("polygon-mumbai")).toBe("Polygon Mumbai"); + expect(blockchainToDisplayName("base-goerli")).toBe("Base Goerli"); + }); + + test("should handle all blockchain types", () => { + // Test that all blockchains in the type have a corresponding display name + for (const blockchain of BLOCKCHAINS_INCLUDING_TESTNETS) { + const displayName = blockchainToDisplayName(blockchain); + expect(typeof displayName).toBe("string"); + expect(displayName.length).toBeGreaterThan(0); + } + }); + }); + + describe("isBlockchain", () => { + test("should return true for valid mainnet blockchains", () => { + expect(isBlockchain("ethereum")).toBe(true); + expect(isBlockchain("solana")).toBe(true); + expect(isBlockchain("polygon")).toBe(true); + }); + + test("should return false for testnet blockchains", () => { + expect(isBlockchain("ethereum-sepolia")).toBe(false); + expect(isBlockchain("polygon-mumbai")).toBe(false); + expect(isBlockchain("base-goerli")).toBe(false); + }); + + test("should return false for invalid values", () => { + expect(isBlockchain("invalid")).toBe(false); + expect(isBlockchain(123)).toBe(false); + expect(isBlockchain(null)).toBe(false); + expect(isBlockchain(undefined)).toBe(false); + }); + + test("should work with type parameter", () => { + const value: unknown = "ethereum"; + if (isBlockchain<"ethereum">(value)) { + expect(value).toBe("ethereum"); + } + }); + + test("should work with expected blockchain parameter", () => { + expect(isBlockchain("ethereum", "ethereum")).toBe(true); + expect(isBlockchain("ethereum", "solana")).toBe(false); + }); + }); + + describe("isBlockchainIncludingTestnets", () => { + test("should return true for valid mainnet blockchains", () => { + expect(isBlockchainIncludingTestnets("ethereum")).toBe(true); + expect(isBlockchainIncludingTestnets("solana")).toBe(true); + expect(isBlockchainIncludingTestnets("polygon")).toBe(true); + }); + + test("should return true for valid testnet blockchains", () => { + expect(isBlockchainIncludingTestnets("ethereum-sepolia")).toBe(true); + expect(isBlockchainIncludingTestnets("polygon-mumbai")).toBe(true); + expect(isBlockchainIncludingTestnets("base-goerli")).toBe(true); + }); + + test("should return false for invalid values", () => { + expect(isBlockchainIncludingTestnets("invalid")).toBe(false); + expect(isBlockchainIncludingTestnets(123)).toBe(false); + expect(isBlockchainIncludingTestnets(null)).toBe(false); + expect(isBlockchainIncludingTestnets(undefined)).toBe(false); + }); + + test("should work with type parameter", () => { + const value: unknown = "ethereum-sepolia"; + if (isBlockchainIncludingTestnets<"ethereum-sepolia">(value)) { + expect(value).toBe("ethereum-sepolia"); + } + }); + + test("should work with expected blockchain parameter", () => { + expect(isBlockchainIncludingTestnets("ethereum-sepolia", "ethereum-sepolia")).toBe(true); + expect(isBlockchainIncludingTestnets("ethereum-sepolia", "ethereum")).toBe(false); + }); + + test("should handle all blockchain types including testnets", () => { + // Test that all blockchains in the type are recognized + for (const blockchain of BLOCKCHAINS_INCLUDING_TESTNETS) { + expect(isBlockchainIncludingTestnets(blockchain)).toBe(true); + } + }); + }); +}); \ No newline at end of file diff --git a/packages/common/base/src/types/Crossmint.test.ts b/packages/common/base/src/types/Crossmint.test.ts new file mode 100644 index 000000000..1837848fb --- /dev/null +++ b/packages/common/base/src/types/Crossmint.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, test } from "vitest"; +import { createCrossmint } from "./Crossmint"; + +const VALID_API_KEY = + "ck_development_A61UZQnvjSQcM5qVBaBactgqebxafWAVsNdD2xLkgBxoYuH5q2guM8r9DUmZQzE1WYyoByGVYpEG2o9gVSzAZFsrLbfKGERUJ6D5CW6S9AsJGAc3ctgrsD4n2ioekzGj7KPbLwT3SysDjMamYXLxEroUbQSdwf6aLF4zeEpECq2crkTUQeLFzxzmjWNxFDHFYefDrfrFPCURvBXJLf5pCxCQ"; + +describe("createCrossmint", () => { + test("should create Crossmint instance with valid API key", () => { + const config = { apiKey: VALID_API_KEY }; + const result = createCrossmint(config); + + expect(result).toEqual({ + apiKey: VALID_API_KEY, + }); + }); + + test("should create Crossmint instance with JWT", () => { + const jwt = "test.jwt.token"; + const config = { apiKey: VALID_API_KEY, jwt }; + const result = createCrossmint(config); + + expect(result).toEqual({ + apiKey: VALID_API_KEY, + jwt, + }); + }); + + test("should create Crossmint instance with override base URL", () => { + const overrideBaseUrl = "https://custom-api.example.com"; + const config = { apiKey: VALID_API_KEY, overrideBaseUrl }; + const result = createCrossmint(config); + + expect(result).toEqual({ + apiKey: VALID_API_KEY, + overrideBaseUrl, + }); + }); + + test("should create Crossmint instance with all optional parameters", () => { + const jwt = "test.jwt.token"; + const overrideBaseUrl = "https://custom-api.example.com"; + const config = { apiKey: VALID_API_KEY, jwt, overrideBaseUrl }; + const result = createCrossmint(config); + + expect(result).toEqual({ + apiKey: VALID_API_KEY, + jwt, + overrideBaseUrl, + }); + }); + + test("should throw error for invalid API key", () => { + const config = { apiKey: "invalid-key" }; + expect(() => createCrossmint(config)).toThrow("Malformed API key. Must start with 'ck' or 'sk'."); + }); + + test("should validate API key with expectations", () => { + const config = { apiKey: VALID_API_KEY }; + const apiKeyExpectations = { environment: "production" }; + expect(() => createCrossmint(config, apiKeyExpectations)).toThrow( + "Disallowed API key. You passed a development API key, but a production API key is required." + ); + }); + + test("should handle undefined API key", () => { + const config = { apiKey: undefined } as any; + expect(() => createCrossmint(config)).toThrow("Cannot read properties of undefined"); + }); + + test("should handle invalid API key", () => { + const config = { apiKey: "abc" } as any; + expect(() => createCrossmint(config)).toThrow("Malformed API key. Must start with 'ck' or 'sk'."); + }); +}); \ No newline at end of file diff --git a/packages/common/base/src/types/utils.test.ts b/packages/common/base/src/types/utils.test.ts new file mode 100644 index 000000000..1857c57e7 --- /dev/null +++ b/packages/common/base/src/types/utils.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "vitest"; +import { objectValues } from "./utils"; + +describe("type utils", () => { + describe("objectValues", () => { + test("should return array of values from object", () => { + const obj = { a: 1, b: "test", c: true }; + const values = objectValues(obj); + expect(values).toEqual([1, "test", true]); + }); + + test("should handle empty object", () => { + const obj = {}; + const values = objectValues(obj); + expect(values).toEqual([]); + }); + + test("should handle object with single value", () => { + const obj = { key: "value" }; + const values = objectValues(obj); + expect(values).toEqual(["value"]); + }); + + test("should handle object with nested objects", () => { + const obj = { a: { x: 1 }, b: { y: 2 } }; + const values = objectValues(obj); + expect(values).toEqual([{ x: 1 }, { y: 2 }]); + }); + + test("should return immutable array", () => { + const obj = { a: 1, b: 2 }; + const values = objectValues(obj); + expect(Object.isFrozen(values)).toBe(true); + expect(() => { + // @ts-expect-error - Testing runtime immutability + values.push(3); + }).toThrow(); + }); + }); +}); \ No newline at end of file diff --git a/packages/common/base/src/types/utils.ts b/packages/common/base/src/types/utils.ts index 4561ec094..38af42d89 100644 --- a/packages/common/base/src/types/utils.ts +++ b/packages/common/base/src/types/utils.ts @@ -1,4 +1,4 @@ export type ObjectValues = T[keyof T]; export function objectValues(obj: T): ReadonlyArray { - return Object.values(obj); + return Object.freeze(Object.values(obj)); } diff --git a/packages/common/base/vitest.config.ts b/packages/common/base/vitest.config.ts new file mode 100644 index 000000000..72871a256 --- /dev/null +++ b/packages/common/base/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "vitest/config"; +import path from "path"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + }, + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, +}); \ No newline at end of file