Skip to content
Merged
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
61 changes: 61 additions & 0 deletions src/3.0/validate/__tests__/documentLoader-http.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>((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<void>((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);
});
});
124 changes: 124 additions & 0 deletions src/3.0/validate/__tests__/validate.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {
"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();
}
});
});
});
11 changes: 11 additions & 0 deletions src/3.0/validate/contexts/CustomContext.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"@context": {
"@version": 1.1,
"@protected": true,

"key1": "xsd:string",
"key2": "xsd:string",
"key3": "xsd:string",
"key4": "xsd:string"
}
}
19 changes: 19 additions & 0 deletions src/3.0/validate/contexts/DrivingLicenceCredential.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
99 changes: 99 additions & 0 deletions src/3.0/validate/contexts/OpenAttestation.v3.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
}
}
}
53 changes: 32 additions & 21 deletions src/3.0/validate/validate.ts
Original file line number Diff line number Diff line change
@@ -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") {
Expand Down Expand Up @@ -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<string, unknown> = {
"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<string, Promise<any>> = 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);
Expand Down
Loading