diff --git a/@shared/api/helpers/__tests__/fetchCollectibles.test.ts b/@shared/api/helpers/__tests__/fetchCollectibles.test.ts index 28e11f196c..c61997741a 100644 --- a/@shared/api/helpers/__tests__/fetchCollectibles.test.ts +++ b/@shared/api/helpers/__tests__/fetchCollectibles.test.ts @@ -1,4 +1,7 @@ -import { fetchCollectibles } from "../fetchCollectibles"; +import { + fetchCollectibleMetadata, + fetchCollectibles, +} from "../fetchCollectibles"; import { fetchMetadataJson } from "../fetchMetadataJson"; import { TESTNET_NETWORK_DETAILS } from "@shared/constants/stellar"; import { SERVICE_TYPES } from "@shared/constants/services"; @@ -15,6 +18,65 @@ jest.mock("../extensionMessaging", () => ({ const mockedFetchMetadataJson = fetchMetadataJson as jest.Mock; const mockedSendMessageToBackground = sendMessageToBackground as jest.Mock; +describe("fetchCollectibleMetadata", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should read metadata embedded in a data URI without fetching", async () => { + const inlineMetadata = { + name: "Stroopies 0", + image: "data:image/svg+xml,", + attributes: [{ trait_type: "role", value: 3 }], + }; + + const metadata = await fetchCollectibleMetadata( + `data:application/json,${JSON.stringify(inlineMetadata)}`, + ); + + expect(mockedFetchMetadataJson).not.toHaveBeenCalled(); + expect(metadata).toEqual({ + name: "Stroopies 0", + description: undefined, + image: "data:image/svg+xml,", + externalUrl: undefined, + attributes: [{ traitType: "role", value: 3 }], + }); + }); + + it("should return null for a data URI that cannot be parsed", async () => { + const metadata = await fetchCollectibleMetadata("data:text/html,
hi
"); + + expect(mockedFetchMetadataJson).not.toHaveBeenCalled(); + expect(metadata).toBeNull(); + }); + + it("should keep traits with a value of 0 and drop traits with no value", async () => { + mockedFetchMetadataJson.mockResolvedValue({ + name: "Collectible Name", + attributes: [ + { trait_type: "arms", value: 0 }, + { trait_type: "hat", value: 8 }, + { trait_type: "color", value: "blue" }, + { trait_type: "empty", value: "" }, + { trait_type: "missing" }, + { trait_type: "nullish", value: null }, + { value: "no trait type" }, + ], + }); + + const metadata = await fetchCollectibleMetadata( + "https://nftcalendar.io/token/1", + ); + + expect(metadata?.attributes).toEqual([ + { traitType: "arms", value: 0 }, + { traitType: "hat", value: 8 }, + { traitType: "color", value: "blue" }, + ]); + }); +}); + describe("fetchCollectibles", () => { beforeEach(() => { jest.clearAllMocks(); diff --git a/@shared/api/helpers/__tests__/parseMetadataDataUri.test.ts b/@shared/api/helpers/__tests__/parseMetadataDataUri.test.ts new file mode 100644 index 0000000000..86cc04f99f --- /dev/null +++ b/@shared/api/helpers/__tests__/parseMetadataDataUri.test.ts @@ -0,0 +1,135 @@ +import { MAX_METADATA_BYTES } from "../fetchMetadataJson"; +import { + isMetadataDataUri, + parseMetadataDataUri, +} from "../parseMetadataDataUri"; + +const metadata = { + name: "Stroopies 0", + image: + "data:image/svg+xml,", + attributes: [ + { trait_type: "role", value: 3 }, + { trait_type: "arms", value: 0 }, + ], +}; + +const toBase64 = (s: string) => Buffer.from(s, "utf-8").toString("base64"); + +describe("isMetadataDataUri", () => { + it("matches data URIs regardless of scheme casing", () => { + expect(isMetadataDataUri("data:application/json,{}")).toBe(true); + expect(isMetadataDataUri("DATA:application/json,{}")).toBe(true); + }); + + it("does not match remote or empty URIs", () => { + expect(isMetadataDataUri("https://example.com/metadata.json")).toBe(false); + expect(isMetadataDataUri("ipfs://bafy")).toBe(false); + expect(isMetadataDataUri("")).toBe(false); + }); +}); + +describe("parseMetadataDataUri", () => { + it("parses a raw (unencoded) JSON payload with an inline SVG image", () => { + const uri = `data:application/json,${JSON.stringify(metadata)}`; + + expect(parseMetadataDataUri(uri)).toEqual(metadata); + }); + + it("parses a percent-encoded JSON payload", () => { + const uri = `data:application/json,${encodeURIComponent( + JSON.stringify(metadata), + )}`; + + expect(parseMetadataDataUri(uri)).toEqual(metadata); + }); + + it("parses a base64 JSON payload, including non-ASCII text", () => { + const doc = { name: "Café ☕" }; + const uri = `data:application/json;base64,${toBase64(JSON.stringify(doc))}`; + + expect(parseMetadataDataUri(uri)).toEqual(doc); + }); + + it("parses a base64 payload whose characters are percent-encoded", () => { + const doc = { name: "AB" }; + const encoded = encodeURIComponent(toBase64(JSON.stringify(doc))); + expect(encoded).toContain("%3D"); + + expect( + parseMetadataDataUri(`data:application/json;base64,${encoded}`), + ).toEqual(doc); + }); + + it("keeps percent escapes inside a raw JSON payload as written", () => { + const doc = { + name: "X", + image: `data:image/svg+xml,${encodeURIComponent( + ``, + )}`, + }; + const uri = `data:application/json,${JSON.stringify(doc)}`; + + expect(parseMetadataDataUri(uri)).toEqual(doc); + }); + + it("accepts media type parameters and mixed casing", () => { + const uri = `data:Application/JSON;charset=utf-8;base64,${toBase64( + JSON.stringify(metadata), + )}`; + + expect(parseMetadataDataUri(uri)).toEqual(metadata); + }); + + it("falls back to the raw payload when it is not valid percent-encoding", () => { + const doc = { + image: "data:image/svg+xml,", + }; + const uri = `data:application/json,${JSON.stringify(doc)}`; + + expect(parseMetadataDataUri(uri)).toEqual(doc); + }); + + it("rejects anything that is not a data:application/json URI", () => { + [ + "https://example.com/metadata.json", + "data:text/html,hi
", + 'data:,{"name":"x"}', + "data:application/json", + ].forEach((uri) => { + expect(() => parseMetadataDataUri(uri)).toThrow( + "not a data:application/json URI", + ); + }); + }); + + it("rejects malformed JSON", () => { + expect(() => parseMetadataDataUri("data:application/json,{nope")).toThrow(); + }); + + it("rejects malformed base64", () => { + expect(() => + parseMetadataDataUri("data:application/json;base64,***"), + ).toThrow(); + }); + + it("rejects JSON that is not an object", () => { + expect(() => parseMetadataDataUri("data:application/json,[1,2]")).toThrow( + "not a JSON object", + ); + expect(() => parseMetadataDataUri("data:application/json,null")).toThrow( + "not a JSON object", + ); + expect(() => parseMetadataDataUri('data:application/json,"text"')).toThrow( + "not a JSON object", + ); + }); + + it("rejects a URI larger than MAX_METADATA_BYTES", () => { + const uri = `data:application/json,${JSON.stringify({ + name: "x".repeat(MAX_METADATA_BYTES), + })}`; + + expect(() => parseMetadataDataUri(uri)).toThrow("uri too large"); + }); +}); diff --git a/@shared/api/helpers/fetchCollectibles.ts b/@shared/api/helpers/fetchCollectibles.ts index 69f419c977..ab61551681 100644 --- a/@shared/api/helpers/fetchCollectibles.ts +++ b/@shared/api/helpers/fetchCollectibles.ts @@ -7,6 +7,10 @@ import { } from "../types"; import { NetworkDetails } from "@shared/constants/stellar"; import { fetchMetadataJson } from "./fetchMetadataJson"; +import { + isMetadataDataUri, + parseMetadataDataUri, +} from "./parseMetadataDataUri"; import { fetchBackendV2 } from "./fetchBackendV2"; import { redactErrorBody } from "./redactErrorBody"; @@ -15,7 +19,7 @@ import { redactErrorBody } from "./redactErrorBody"; * The metadata follows the NFT metadata standard and includes name, description, * image, external URL, and attributes. * - * @param {string} tokenUri - The URI where the collectible metadata is hosted + * @param {string} tokenUri - The URI where the collectible metadata is hosted (https), or a `data:application/json` URI that embeds it * @returns {Promise