From d0530616923fa8c11fbe7c4d532ee08322269611 Mon Sep 17 00:00:00 2001 From: Isaackps Date: Wed, 22 Apr 2026 19:17:51 +0800 Subject: [PATCH] fix: updated documentLoader to look out for schemata.openattestation and read from the stored json --- .../__tests__/documentLoader-http.test.ts | 61 +++++++++ src/3.0/validate/__tests__/validate.test.ts | 124 ++++++++++++++++++ src/3.0/validate/contexts/CustomContext.json | 11 ++ .../contexts/DrivingLicenceCredential.json | 19 +++ .../validate/contexts/OpenAttestation.v3.json | 99 ++++++++++++++ src/3.0/validate/validate.ts | 53 +++++--- 6 files changed, 346 insertions(+), 21 deletions(-) create mode 100644 src/3.0/validate/__tests__/documentLoader-http.test.ts create mode 100644 src/3.0/validate/__tests__/validate.test.ts create mode 100644 src/3.0/validate/contexts/CustomContext.json create mode 100644 src/3.0/validate/contexts/DrivingLicenceCredential.json create mode 100644 src/3.0/validate/contexts/OpenAttestation.v3.json diff --git a/src/3.0/validate/__tests__/documentLoader-http.test.ts b/src/3.0/validate/__tests__/documentLoader-http.test.ts new file mode 100644 index 00000000..763431b1 --- /dev/null +++ b/src/3.0/validate/__tests__/documentLoader-http.test.ts @@ -0,0 +1,61 @@ +import { createServer, Server } from "http"; +import type { AddressInfo } from "net"; + +import { documentLoader } from "../validate"; + +type RequestLog = { path: string | undefined; method: string | undefined }; + +/** + * Stands up a local HTTP server and asserts the real jsonld node loader + * issues an actual HTTP request for non-hardcoded URLs. This is the ground + * truth that the mock-based fallback tests rely on: without this, a passing + * mock proves only that a function was called, not that the network was. + */ +describe("documentLoader issues a real HTTP request for non-schemata URLs", () => { + let server: Server; + let baseUrl: string; + const contextBody = { "@context": { name: "http://schema.org/name" } }; + const requests: RequestLog[] = []; + + beforeAll(async () => { + server = createServer((req, res) => { + requests.push({ path: req.url, method: req.method }); + res.setHeader("Content-Type", "application/ld+json"); + res.end(JSON.stringify(contextBody)); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve())); + const { port } = server.address() as AddressInfo; + baseUrl = `http://127.0.0.1:${port}`; + }); + + afterAll(async () => { + await new Promise((resolve, reject) => server.close((err) => (err ? reject(err) : resolve()))); + }); + + beforeEach(() => { + requests.length = 0; + }); + + it("sends an HTTP GET to the endpoint and returns the server's JSON", async () => { + // unique URL per run so the module-level context cache can't shortcut us + const path = `/context-${Date.now()}-${Math.random().toString(36).slice(2)}.json`; + const url = `${baseUrl}${path}`; + + const result = await documentLoader(url); + + expect(requests).toHaveLength(1); + expect(requests[0].method).toBe("GET"); + expect(requests[0].path).toBe(path); + expect(result.document).toEqual(contextBody); + expect(result.documentUrl).toBe(url); + }); + + it("does NOT send any HTTP request for a schemata.openattestation.com URL", async () => { + await documentLoader("https://schemata.openattestation.com/com/openattestation/1.0/OpenAttestation.v3.json"); + await documentLoader("https://www.schemata.openattestation.com/com/openattestation/3.0/OpenAttestation.v3.json"); + await documentLoader("https://schemata.openattestation.com/com/openattestation/1.0/DrivingLicenceCredential.json"); + await documentLoader("https://schemata.openattestation.com/com/openattestation/1.0/CustomContext.json"); + + expect(requests).toHaveLength(0); + }); +}); diff --git a/src/3.0/validate/__tests__/validate.test.ts b/src/3.0/validate/__tests__/validate.test.ts new file mode 100644 index 00000000..98c42d95 --- /dev/null +++ b/src/3.0/validate/__tests__/validate.test.ts @@ -0,0 +1,124 @@ +import { resolveHardcodedContext } from "../validate"; +import openAttestationV3Context from "../contexts/OpenAttestation.v3.json"; +import drivingLicenceCredentialContext from "../contexts/DrivingLicenceCredential.json"; +import customContext from "../contexts/CustomContext.json"; +import didWrapped from "../../../../test/fixtures/v3/did-wrapped.json"; +import notObfuscatedWrapped from "../../../../test/fixtures/v3/not-obfuscated-wrapped.json"; +import obfuscatedWrapped from "../../../../test/fixtures/v3/obfuscated-wrapped.json"; +import rawDocument from "../../../../test/fixtures/v3/raw-document.json"; +import wrappedTransferableDocument from "../../../../test/fixtures/v3/wrapped-transferable-document.json"; + +const fixtures = { + "did-wrapped.json": didWrapped, + "not-obfuscated-wrapped.json": notObfuscatedWrapped, + "obfuscated-wrapped.json": obfuscatedWrapped, + "raw-document.json": rawDocument, + "wrapped-transferable-document.json": wrappedTransferableDocument, +}; + +const expectedLocalContextByFilename: Record = { + "OpenAttestation.v3.json": openAttestationV3Context, + "DrivingLicenceCredential.json": drivingLicenceCredentialContext, + "CustomContext.json": customContext, +}; + +const isSchemataOpenAttestationUrl = (url: string): boolean => { + try { + const { hostname } = new URL(url); + return hostname === "schemata.openattestation.com" || hostname === "www.schemata.openattestation.com"; + } catch { + return false; + } +}; + +describe("resolveHardcodedContext", () => { + describe("OpenAttestation.v3.json", () => { + it("resolves the canonical schemata URL", () => { + expect( + resolveHardcodedContext("https://schemata.openattestation.com/com/openattestation/1.0/OpenAttestation.v3.json"), + ).toBe(openAttestationV3Context); + }); + + it("resolves the www. prefix variant", () => { + expect( + resolveHardcodedContext( + "https://www.schemata.openattestation.com/com/openattestation/1.0/OpenAttestation.v3.json", + ), + ).toBe(openAttestationV3Context); + }); + + it("resolves a different path version (e.g. 3.0)", () => { + expect( + resolveHardcodedContext( + "https://www.schemata.openattestation.com/com/openattestation/3.0/OpenAttestation.v3.json", + ), + ).toBe(openAttestationV3Context); + }); + }); + + it("resolves DrivingLicenceCredential.json", () => { + expect( + resolveHardcodedContext( + "https://schemata.openattestation.com/com/openattestation/1.0/DrivingLicenceCredential.json", + ), + ).toBe(drivingLicenceCredentialContext); + }); + + it("resolves CustomContext.json", () => { + expect( + resolveHardcodedContext("https://schemata.openattestation.com/com/openattestation/1.0/CustomContext.json"), + ).toBe(customContext); + }); + + describe("non-matching URLs", () => { + it("returns undefined for an unknown host", () => { + expect(resolveHardcodedContext("https://www.w3.org/2018/credentials/v1")).toBeUndefined(); + }); + + it("returns undefined for a lookalike host", () => { + expect( + resolveHardcodedContext("https://schemata.openattestation.com.evil.example/OpenAttestation.v3.json"), + ).toBeUndefined(); + }); + + it("returns undefined for a schemata host with an unknown filename", () => { + expect( + resolveHardcodedContext("https://schemata.openattestation.com/com/openattestation/1.0/Unknown.json"), + ).toBeUndefined(); + }); + + it("returns undefined for a malformed URL", () => { + expect(resolveHardcodedContext("not a url")).toBeUndefined(); + }); + }); + + describe("v3 fixtures", () => { + it.each(Object.entries(fixtures))( + "%s: every schemata.openattestation.com @context URL resolves to the bundled local JSON", + (_name, fixture) => { + const contextUrls: string[] = (fixture as { "@context": string[] })["@context"]; + expect(Array.isArray(contextUrls)).toBe(true); + + const schemataUrls = contextUrls.filter(isSchemataOpenAttestationUrl); + // every fixture references all three schemata contexts — guard against silent fixture changes + expect(schemataUrls).toHaveLength(3); + + for (const url of schemataUrls) { + const filename = new URL(url).pathname.split("/").pop() as string; + const expected = expectedLocalContextByFilename[filename]; + expect(expected).toBeDefined(); + expect(resolveHardcodedContext(url)).toBe(expected); + } + }, + ); + + it.each(Object.entries(fixtures))("%s: non-schemata @context URLs are not hardcoded", (_name, fixture) => { + const contextUrls: string[] = (fixture as { "@context": string[] })["@context"]; + const nonSchemataUrls = contextUrls.filter((url) => !isSchemataOpenAttestationUrl(url)); + + for (const url of nonSchemataUrls) { + expect(resolveHardcodedContext(url)).toBeUndefined(); + } + }); + }); +}); diff --git a/src/3.0/validate/contexts/CustomContext.json b/src/3.0/validate/contexts/CustomContext.json new file mode 100644 index 00000000..722bbc28 --- /dev/null +++ b/src/3.0/validate/contexts/CustomContext.json @@ -0,0 +1,11 @@ +{ + "@context": { + "@version": 1.1, + "@protected": true, + + "key1": "xsd:string", + "key2": "xsd:string", + "key3": "xsd:string", + "key4": "xsd:string" + } +} diff --git a/src/3.0/validate/contexts/DrivingLicenceCredential.json b/src/3.0/validate/contexts/DrivingLicenceCredential.json new file mode 100644 index 00000000..6c89c240 --- /dev/null +++ b/src/3.0/validate/contexts/DrivingLicenceCredential.json @@ -0,0 +1,19 @@ +{ + "@context": [ + { + "@version": 1.1 + }, + "https://www.w3.org/ns/odrl.jsonld", + { + "ex": "https://example.org/examples#", + "schema": "http://schema.org/", + "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#", + "DrivingLicenceCredential": "ex:DrivingLicenceCredential", + "licenseNumber": "ex:licenseNumber", + "birthDate": "ex:birthDate", + "class": "ex:class", + "name": "ex:name", + "effectiveDate": "ex:effectiveDate" + } + ] +} diff --git a/src/3.0/validate/contexts/OpenAttestation.v3.json b/src/3.0/validate/contexts/OpenAttestation.v3.json new file mode 100644 index 00000000..a987d4b9 --- /dev/null +++ b/src/3.0/validate/contexts/OpenAttestation.v3.json @@ -0,0 +1,99 @@ +{ + "@context": { + "@version": 1.1, + "@protected": true, + "OpenAttestationCredential": { + "@id": "https://schemata.openattestation.com/vocab/#OpenAttestationCredential", + "@context": { + "openAttestationMetadata": { + "@id": "https://schemata.openattestation.com/vocab/#openAttestationMetadata", + "@context": { + "identityProof": { + "@id": "https://schemata.openattestation.com/vocab/#identityProof", + "@context": { + "@version": 1.1, + "@protected": true, + "id": "@id", + "type": "@type", + "identifier": "xsd:string", + "DNS-TXT": "xsd:string" + } + }, + "template": { + "@id": "https://schemata.openattestation.com/vocab/#template", + "@context": { + "@version": 1.1, + "@protected": true, + "id": "@id", + "type": "@type", + "name": "xsd:string", + "url": "xsd:string", + "EMBEDDED_RENDERER": "xsd:string" + } + } + } + }, + "attachments": { + "@id": "https://schemata.openattestation.com/vocab/#openAttestationMetadata", + "@context": { + "data": "xsd:string", + "fileName": "xsd:string", + "mimeType": "xsd:string" + } + }, + "reference": "xsd:string", + "name": "xsd:string", + "network": { + "@id": "https://schemata.openattestation.com/vocab/#network", + "@context": { + "chain": "xsd:string", + "chainId": "xsd:string" + } + } + } + }, + "OpenAttestationIssuer": { + "@id": "https://schemata.openattestation.com/vocab/#OpenAttestationIssuer", + "@context": { + "name": "xsd:string" + } + }, + "OpenAttestationMerkleProofSignature2018": { + "@id": "https://schemata.openattestation.com/vocab/#OpenAttestationCredential", + "@context": { + "targetHash": "xsd:string", + "merkleRoot": "xsd:string", + "proofPurpose": "xsd:string", + "salts": "xsd:string", + "proofs": { + "@id": "https://schemata.openattestation.com/vocab/#salts", + "@container": ["@index", "@set"] + }, + "privacy": { + "@id": "https://schemata.openattestation.com/vocab/#privacy", + "@context": { + "@version": 1.1, + "@protected": true, + "obfuscated": { + "@id": "https://schemata.openattestation.com/vocab/#obfuscatedData", + "@container": ["@index", "@set"] + } + } + } + } + }, + "OpenAttestationProofMethod": { + "@id": "https://schemata.openattestation.com/vocab/#OpenAttestationCredential", + "@context": { + "method": "xsd:string", + "value": "xsd:string", + "revocation": { + "@id": "https://schemata.openattestation.com/vocab/#openAttestationRevocation", + "@context": { + "location": "xsd:string" + } + } + } + } + } +} diff --git a/src/3.0/validate/validate.ts b/src/3.0/validate/validate.ts index c6cde59d..481dacc0 100644 --- a/src/3.0/validate/validate.ts +++ b/src/3.0/validate/validate.ts @@ -1,7 +1,9 @@ import { OpenAttestationDocument } from "../../__generated__/schema.3.0"; import { WrappedDocument } from "../../3.0/types"; import { documentLoaders, expand } from "@govtechsg/jsonld"; -import fetch from "cross-fetch"; +import openAttestationV3Context from "./contexts/OpenAttestation.v3.json"; +import drivingLicenceCredentialContext from "./contexts/DrivingLicenceCredential.json"; +import customContext from "./contexts/CustomContext.json"; const getId = (objectOrString: string | { id: string }): string => { if (typeof objectOrString === "string") { @@ -39,33 +41,42 @@ const isValidRFC3986 = (str: any) => { return rfc3986.test(str); }; -const preloadedContextList = [ - "https://www.w3.org/2018/credentials/v1", - "https://www.w3.org/2018/credentials/examples/v1", - "https://schemata.openattestation.com/com/openattestation/1.0/DrivingLicenceCredential.json", - "https://schemata.openattestation.com/com/openattestation/1.0/OpenAttestation.v3.json", - "https://schemata.openattestation.com/com/openattestation/1.0/CustomContext.json", -]; +const openAttestationSchemataHosts = new Set(["schemata.openattestation.com", "www.schemata.openattestation.com"]); +const hardcodedContextsByFilename: Record = { + "OpenAttestation.v3.json": openAttestationV3Context, + "DrivingLicenceCredential.json": drivingLicenceCredentialContext, + "CustomContext.json": customContext, +}; + +export const resolveHardcodedContext = (url: string): unknown | undefined => { + try { + const parsed = new URL(url); + if (!openAttestationSchemataHosts.has(parsed.hostname)) return undefined; + const filename = parsed.pathname.split("/").pop(); + return filename ? hardcodedContextsByFilename[filename] : undefined; + } catch { + return undefined; + } +}; + const contexts: Map> = new Map(); const nodeDocumentLoader = documentLoaders.xhr ? documentLoaders.xhr() : documentLoaders.node(); -let preload = true; -const documentLoader = async (url: string) => { - if (preload) { - preload = false; - for (const url of preloadedContextList) { - contexts.set( - url, - fetch(url, { headers: { accept: "application/json, application/ld+json" } }).then((res: any) => res.json()), - ); - } +export const documentLoader = async (url: string) => { + const hardcoded = resolveHardcodedContext(url); + if (hardcoded !== undefined) { + return { + contextUrl: undefined, // this is for a context via a link header + document: hardcoded, // served from the bundled local copy regardless of host/version in the URL + documentUrl: url, // this is the actual context URL after redirects + }; } if (contexts.get(url)) { const promise = contexts.get(url); return { - contextUrl: undefined, // this is for a context via a link header - document: await promise, // this is the actual document that was loaded - documentUrl: url, // this is the actual context URL after redirects + contextUrl: undefined, + document: await promise, + documentUrl: url, }; } else { const promise = nodeDocumentLoader(url);