diff --git a/README.md b/README.md index 4e7d4c2..fc666ad 100644 --- a/README.md +++ b/README.md @@ -290,6 +290,35 @@ console.log('Resolved FHIR resources:', resolved.fhirResources); This example above demonstrates the complete lifecycle: SHL generation, content addition, manifest builder persistence in server-side database, manifest serving, and client-side resolution. In a real application, you would implement persistence for the manifest builder state and serve the manifest endpoint from your backend server. +### Directories +A `Directory` is a lightweight, serializable collection of issuer metadata used by this library: each entry contains an issuer URL, its JWK descriptors (public keys) and optional CRL entries (revoked resource ids). + +It provides a local/cached source of JWKS + CRL data so verification and lookup code can resolve public keys and revocation information without hitting network endpoints repeatedly. + +It can be built through the following methods: + +1. Build from a JSON manifest: `Directory.fromJSON` +2. Build by fetching from issuer endpoints: `Directory.fromURLs` +3. Build by loading the VCI snapshot: `Directory.fromVCI` + +#### Building a Directory from VCI Snapshot +`Directory.fromVCI` is a convenience helper that fetches the VCI Directory snapshot published by The Commons Project and returns a Directory instance built from that snapshot. + +Use it when you want a quick, canonical directory of issuer metadata (JWKS + CRLs) without manually assembling or maintaining a directory JSON. + +```typescript +import { Directory } from 'kill-the-clipboard' + +// top-level async context or inside an async function +try { + const directory = await Directory.fromVCI() + const issuers = directory.getIssuerInfo() + console.log('Loaded issuers:', issuers.length) +} catch (err) { + console.error('Failed to load VCI Directory:', err) +} +``` + ### Error Handling The library provides granular error handling with specific error codes for different failure scenarios. Check each method documentation for the specific errors that can be thrown. diff --git a/demo/medplum-shl/package.json b/demo/medplum-shl/package.json index 6799634..c89744d 100644 --- a/demo/medplum-shl/package.json +++ b/demo/medplum-shl/package.json @@ -30,7 +30,7 @@ "@tabler/icons-react": "^3.34.1", "argon2": "^0.44.0", "kill-the-clipboard": "workspace:../..", - "next": "15.4.6", + "next": "15.5.7", "react": "19.1.1", "react-dom": "19.1.1", "uuid": "^11.1.0" diff --git a/demo/shl/package.json b/demo/shl/package.json index dee8fd8..082b723 100644 --- a/demo/shl/package.json +++ b/demo/shl/package.json @@ -32,7 +32,7 @@ "@tabler/icons-react": "^3.34.1", "argon2": "^0.44.0", "kill-the-clipboard": "workspace:../..", - "next": "15.5.3", + "next": "15.5.7", "react": "19.1.1", "react-dom": "19.1.1", "uuid": "^11.1.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c5f15da..2b17f04 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -130,8 +130,8 @@ importers: specifier: workspace:../.. version: link:../.. next: - specifier: 15.4.6 - version: 15.4.6(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + specifier: 15.5.7 + version: 15.5.7(react-dom@19.1.1(react@19.1.1))(react@19.1.1) react: specifier: 19.1.1 version: 19.1.1 @@ -219,8 +219,8 @@ importers: specifier: workspace:../.. version: link:../.. next: - specifier: 15.5.3 - version: 15.5.3(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + specifier: 15.5.7 + version: 15.5.7(react-dom@19.1.1(react@19.1.1))(react@19.1.1) react: specifier: 19.1.1 version: 19.1.1 @@ -1233,11 +1233,8 @@ packages: '@neon-rs/load@0.0.4': resolution: {integrity: sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==} - '@next/env@15.4.6': - resolution: {integrity: sha512-yHDKVTcHrZy/8TWhj0B23ylKv5ypocuCwey9ZqPyv4rPdUdRzpGCkSi03t04KBPyU96kxVtUqx6O3nE1kpxASQ==} - - '@next/env@15.5.3': - resolution: {integrity: sha512-RSEDTRqyihYXygx/OJXwvVupfr9m04+0vH8vyy0HfZ7keRto6VX9BbEk0J2PUk0VGy6YhklJUSrgForov5F9pw==} + '@next/env@15.5.7': + resolution: {integrity: sha512-4h6Y2NyEkIEN7Z8YxkA27pq6zTkS09bUSYC0xjd0NpwFxjnIKeZEeH591o5WECSmjpUhLn3H2QLJcDye3Uzcvg==} '@next/eslint-plugin-next@15.4.6': resolution: {integrity: sha512-2NOu3ln+BTcpnbIDuxx6MNq+pRrCyey4WSXGaJIyt0D2TYicHeO9QrUENNjcf673n3B1s7hsiV5xBYRCK1Q8kA==} @@ -1245,98 +1242,50 @@ packages: '@next/eslint-plugin-next@15.5.3': resolution: {integrity: sha512-SdhaKdko6dpsSr0DldkESItVrnPYB1NS2NpShCSX5lc7SSQmLZt5Mug6t2xbiuVWEVDLZSuIAoQyYVBYp0dR5g==} - '@next/swc-darwin-arm64@15.4.6': - resolution: {integrity: sha512-667R0RTP4DwxzmrqTs4Lr5dcEda9OxuZsVFsjVtxVMVhzSpo6nLclXejJVfQo2/g7/Z9qF3ETDmN3h65mTjpTQ==} + '@next/swc-darwin-arm64@15.5.7': + resolution: {integrity: sha512-IZwtxCEpI91HVU/rAUOOobWSZv4P2DeTtNaCdHqLcTJU4wdNXgAySvKa/qJCgR5m6KI8UsKDXtO2B31jcaw1Yw==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-arm64@15.5.3': - resolution: {integrity: sha512-nzbHQo69+au9wJkGKTU9lP7PXv0d1J5ljFpvb+LnEomLtSbJkbZyEs6sbF3plQmiOB2l9OBtN2tNSvCH1nQ9Jg==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [darwin] - - '@next/swc-darwin-x64@15.4.6': - resolution: {integrity: sha512-KMSFoistFkaiQYVQQnaU9MPWtp/3m0kn2Xed1Ces5ll+ag1+rlac20sxG+MqhH2qYWX1O2GFOATQXEyxKiIscg==} - engines: {node: '>= 10'} - cpu: [x64] - os: [darwin] - - '@next/swc-darwin-x64@15.5.3': - resolution: {integrity: sha512-w83w4SkOOhekJOcA5HBvHyGzgV1W/XvOfpkrxIse4uPWhYTTRwtGEM4v/jiXwNSJvfRvah0H8/uTLBKRXlef8g==} + '@next/swc-darwin-x64@15.5.7': + resolution: {integrity: sha512-UP6CaDBcqaCBuiq/gfCEJw7sPEoX1aIjZHnBWN9v9qYHQdMKvCKcAVs4OX1vIjeE+tC5EIuwDTVIoXpUes29lg==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@15.4.6': - resolution: {integrity: sha512-PnOx1YdO0W7m/HWFeYd2A6JtBO8O8Eb9h6nfJia2Dw1sRHoHpNf6lN1U4GKFRzRDBi9Nq2GrHk9PF3Vmwf7XVw==} + '@next/swc-linux-arm64-gnu@15.5.7': + resolution: {integrity: sha512-NCslw3GrNIw7OgmRBxHtdWFQYhexoUCq+0oS2ccjyYLtcn1SzGzeM54jpTFonIMUjNbHmpKpziXnpxhSWLcmBA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-arm64-gnu@15.5.3': - resolution: {integrity: sha512-+m7pfIs0/yvgVu26ieaKrifV8C8yiLe7jVp9SpcIzg7XmyyNE7toC1fy5IOQozmr6kWl/JONC51osih2RyoXRw==} + '@next/swc-linux-arm64-musl@15.5.7': + resolution: {integrity: sha512-nfymt+SE5cvtTrG9u1wdoxBr9bVB7mtKTcj0ltRn6gkP/2Nu1zM5ei8rwP9qKQP0Y//umK+TtkKgNtfboBxRrw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-arm64-musl@15.4.6': - resolution: {integrity: sha512-XBbuQddtY1p5FGPc2naMO0kqs4YYtLYK/8aPausI5lyOjr4J77KTG9mtlU4P3NwkLI1+OjsPzKVvSJdMs3cFaw==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - - '@next/swc-linux-arm64-musl@15.5.3': - resolution: {integrity: sha512-u3PEIzuguSenoZviZJahNLgCexGFhso5mxWCrrIMdvpZn6lkME5vc/ADZG8UUk5K1uWRy4hqSFECrON6UKQBbQ==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - - '@next/swc-linux-x64-gnu@15.4.6': - resolution: {integrity: sha512-+WTeK7Qdw82ez3U9JgD+igBAP75gqZ1vbK6R8PlEEuY0OIe5FuYXA4aTjL811kWPf7hNeslD4hHK2WoM9W0IgA==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - - '@next/swc-linux-x64-gnu@15.5.3': - resolution: {integrity: sha512-lDtOOScYDZxI2BENN9m0pfVPJDSuUkAD1YXSvlJF0DKwZt0WlA7T7o3wrcEr4Q+iHYGzEaVuZcsIbCps4K27sA==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - - '@next/swc-linux-x64-musl@15.4.6': - resolution: {integrity: sha512-XP824mCbgQsK20jlXKrUpZoh/iO3vUWhMpxCz8oYeagoiZ4V0TQiKy0ASji1KK6IAe3DYGfj5RfKP6+L2020OQ==} + '@next/swc-linux-x64-gnu@15.5.7': + resolution: {integrity: sha512-hvXcZvCaaEbCZcVzcY7E1uXN9xWZfFvkNHwbe/n4OkRhFWrs1J1QV+4U1BN06tXLdaS4DazEGXwgqnu/VMcmqw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-linux-x64-musl@15.5.3': - resolution: {integrity: sha512-9vWVUnsx9PrY2NwdVRJ4dUURAQ8Su0sLRPqcCCxtX5zIQUBES12eRVHq6b70bbfaVaxIDGJN2afHui0eDm+cLg==} + '@next/swc-linux-x64-musl@15.5.7': + resolution: {integrity: sha512-4IUO539b8FmF0odY6/SqANJdgwn1xs1GkPO5doZugwZ3ETF6JUdckk7RGmsfSf7ws8Qb2YB5It33mvNL/0acqA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-win32-arm64-msvc@15.4.6': - resolution: {integrity: sha512-FxrsenhUz0LbgRkNWx6FRRJIPe/MI1JRA4W4EPd5leXO00AZ6YU8v5vfx4MDXTvN77lM/EqsE3+6d2CIeF5NYg==} + '@next/swc-win32-arm64-msvc@15.5.7': + resolution: {integrity: sha512-CpJVTkYI3ZajQkC5vajM7/ApKJUOlm6uP4BknM3XKvJ7VXAvCqSjSLmM0LKdYzn6nBJVSjdclx8nYJSa3xlTgQ==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-arm64-msvc@15.5.3': - resolution: {integrity: sha512-1CU20FZzY9LFQigRi6jM45oJMU3KziA5/sSG+dXeVaTm661snQP6xu3ykGxxwU5sLG3sh14teO/IOEPVsQMRfA==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [win32] - - '@next/swc-win32-x64-msvc@15.4.6': - resolution: {integrity: sha512-T4ufqnZ4u88ZheczkBTtOF+eKaM14V8kbjud/XrAakoM5DKQWjW09vD6B9fsdsWS2T7D5EY31hRHdta7QKWOng==} - engines: {node: '>= 10'} - cpu: [x64] - os: [win32] - - '@next/swc-win32-x64-msvc@15.5.3': - resolution: {integrity: sha512-JMoLAq3n3y5tKXPQwCK5c+6tmwkuFDa2XAxz8Wm4+IVthdBZdZGh+lmiLUHg9f9IDwIQpUjp+ysd6OkYTyZRZw==} + '@next/swc-win32-x64-msvc@15.5.7': + resolution: {integrity: sha512-gMzgBX164I6DN+9/PGA+9dQiwmTkE4TloBNx8Kv9UiGARsr9Nba7IpcBRA1iTV9vwlYnrE3Uy6I7Aj6qLjQuqw==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -3370,29 +3319,8 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - next@15.4.6: - resolution: {integrity: sha512-us++E/Q80/8+UekzB3SAGs71AlLDsadpFMXVNM/uQ0BMwsh9m3mr0UNQIfjKed8vpWXsASe+Qifrnu1oLIcKEQ==} - engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} - hasBin: true - peerDependencies: - '@opentelemetry/api': ^1.1.0 - '@playwright/test': ^1.51.1 - babel-plugin-react-compiler: '*' - react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 - react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 - sass: ^1.3.0 - peerDependenciesMeta: - '@opentelemetry/api': - optional: true - '@playwright/test': - optional: true - babel-plugin-react-compiler: - optional: true - sass: - optional: true - - next@15.5.3: - resolution: {integrity: sha512-r/liNAx16SQj4D+XH/oI1dlpv9tdKJ6cONYPwwcCC46f2NjpaRWY+EKCzULfgQYV6YKXjHBchff2IZBSlZmJNw==} + next@15.5.7: + resolution: {integrity: sha512-+t2/0jIJ48kUpGKkdlhgkv+zPTEOoXyr60qXe68eB/pl3CMJaLeIGjzp5D6Oqt25hCBiBTt8wEeeAzfJvUKnPQ==} engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} hasBin: true peerDependencies: @@ -5552,9 +5480,7 @@ snapshots: '@neon-rs/load@0.0.4': {} - '@next/env@15.4.6': {} - - '@next/env@15.5.3': {} + '@next/env@15.5.7': {} '@next/eslint-plugin-next@15.4.6': dependencies: @@ -5564,52 +5490,28 @@ snapshots: dependencies: fast-glob: 3.3.1 - '@next/swc-darwin-arm64@15.4.6': - optional: true - - '@next/swc-darwin-arm64@15.5.3': - optional: true - - '@next/swc-darwin-x64@15.4.6': - optional: true - - '@next/swc-darwin-x64@15.5.3': - optional: true - - '@next/swc-linux-arm64-gnu@15.4.6': + '@next/swc-darwin-arm64@15.5.7': optional: true - '@next/swc-linux-arm64-gnu@15.5.3': + '@next/swc-darwin-x64@15.5.7': optional: true - '@next/swc-linux-arm64-musl@15.4.6': + '@next/swc-linux-arm64-gnu@15.5.7': optional: true - '@next/swc-linux-arm64-musl@15.5.3': + '@next/swc-linux-arm64-musl@15.5.7': optional: true - '@next/swc-linux-x64-gnu@15.4.6': + '@next/swc-linux-x64-gnu@15.5.7': optional: true - '@next/swc-linux-x64-gnu@15.5.3': + '@next/swc-linux-x64-musl@15.5.7': optional: true - '@next/swc-linux-x64-musl@15.4.6': + '@next/swc-win32-arm64-msvc@15.5.7': optional: true - '@next/swc-linux-x64-musl@15.5.3': - optional: true - - '@next/swc-win32-arm64-msvc@15.4.6': - optional: true - - '@next/swc-win32-arm64-msvc@15.5.3': - optional: true - - '@next/swc-win32-x64-msvc@15.4.6': - optional: true - - '@next/swc-win32-x64-msvc@15.5.3': + '@next/swc-win32-x64-msvc@15.5.7': optional: true '@nodelib/fs.scandir@2.1.5': @@ -7919,32 +7821,9 @@ snapshots: natural-compare@1.4.0: {} - next@15.4.6(react-dom@19.1.1(react@19.1.1))(react@19.1.1): - dependencies: - '@next/env': 15.4.6 - '@swc/helpers': 0.5.15 - caniuse-lite: 1.0.30001743 - postcss: 8.4.31 - react: 19.1.1 - react-dom: 19.1.1(react@19.1.1) - styled-jsx: 5.1.6(react@19.1.1) - optionalDependencies: - '@next/swc-darwin-arm64': 15.4.6 - '@next/swc-darwin-x64': 15.4.6 - '@next/swc-linux-arm64-gnu': 15.4.6 - '@next/swc-linux-arm64-musl': 15.4.6 - '@next/swc-linux-x64-gnu': 15.4.6 - '@next/swc-linux-x64-musl': 15.4.6 - '@next/swc-win32-arm64-msvc': 15.4.6 - '@next/swc-win32-x64-msvc': 15.4.6 - sharp: 0.34.4 - transitivePeerDependencies: - - '@babel/core' - - babel-plugin-macros - - next@15.5.3(react-dom@19.1.1(react@19.1.1))(react@19.1.1): + next@15.5.7(react-dom@19.1.1(react@19.1.1))(react@19.1.1): dependencies: - '@next/env': 15.5.3 + '@next/env': 15.5.7 '@swc/helpers': 0.5.15 caniuse-lite: 1.0.30001743 postcss: 8.4.31 @@ -7952,14 +7831,14 @@ snapshots: react-dom: 19.1.1(react@19.1.1) styled-jsx: 5.1.6(react@19.1.1) optionalDependencies: - '@next/swc-darwin-arm64': 15.5.3 - '@next/swc-darwin-x64': 15.5.3 - '@next/swc-linux-arm64-gnu': 15.5.3 - '@next/swc-linux-arm64-musl': 15.5.3 - '@next/swc-linux-x64-gnu': 15.5.3 - '@next/swc-linux-x64-musl': 15.5.3 - '@next/swc-win32-arm64-msvc': 15.5.3 - '@next/swc-win32-x64-msvc': 15.5.3 + '@next/swc-darwin-arm64': 15.5.7 + '@next/swc-darwin-x64': 15.5.7 + '@next/swc-linux-arm64-gnu': 15.5.7 + '@next/swc-linux-arm64-musl': 15.5.7 + '@next/swc-linux-x64-gnu': 15.5.7 + '@next/swc-linux-x64-musl': 15.5.7 + '@next/swc-win32-arm64-msvc': 15.5.7 + '@next/swc-win32-x64-msvc': 15.5.7 sharp: 0.34.4 transitivePeerDependencies: - '@babel/core' diff --git a/src/shc/directory.ts b/src/shc/directory.ts index 8853a8d..f57ea65 100644 --- a/src/shc/directory.ts +++ b/src/shc/directory.ts @@ -27,6 +27,32 @@ export class Directory { return this.issuerInfo } + /** + * Fetch a snapshot of the VCI Directory published by The Commons Project + * and build a {@link Directory} from it. + * + * This helper fetches a well-known VCI snapshot JSON file and delegates to + * `Directory.fromJSON` to produce a `Directory` instance. If the snapshot + * cannot be retrieved (non-2xx response) the function throws an Error. + * + * @returns A {@link Directory} populated from the VCI snapshot + * @throws Error when the VCI snapshot HTTP fetch returns a non-OK status + * @example + * const directory = await Directory.fromVCI() + */ + static async fromVCI(): Promise { + const vciSnapshotResponse = await fetch( + 'https://raw.githubusercontent.com/the-commons-project/vci-directory/main/logs/vci_snapshot.json' + ) + if (!vciSnapshotResponse.ok) { + throw new Error( + `Failed to fetch VCI Directory snapshot with status ${vciSnapshotResponse.status}` + ) + } + const vciDirectoryJson = await vciSnapshotResponse.json() + return Directory.fromJSON(vciDirectoryJson) + } + /** * Build a Directory from a parsed JSON object matching the published * directory schema. diff --git a/test/shc/directory.test.ts b/test/shc/directory.test.ts index 2e12f7b..b70f067 100644 --- a/test/shc/directory.test.ts +++ b/test/shc/directory.test.ts @@ -2,112 +2,154 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { Directory } from '../../src/shc/directory' import type { DirectoryJSON } from '../../src/shc/types' -describe('Directory', () => { - const ISS_URL = 'https://example.com/issuer' - - afterEach(() => { - vi.restoreAllMocks() - }) - - it('should create a directory from JSON', () => { - const directoryJson = { - directory: 'https://example.com/keystore/directory.json', - issuerInfo: [ +const SAMPLE_DIRECTORY_JSON = { + directory: 'https://example.com/keystore/directory.json', + issuerInfo: [ + { + issuer: { + iss: 'https://example.com/issuer', + name: 'Example Issuer 1', + }, + keys: [ { - issuer: { - iss: 'https://example.com/issuer', - name: 'Example Issuer 1', - }, - keys: [ - { - kty: 'EC', - kid: 'kid-1-simple', - }, - { - kty: 'EC', - kid: 'kid-2-simple', - }, - ], - crls: [ - { - kid: 'kid-2-simple', - method: 'rid', - ctr: 1, - rids: ['revoked-1'], - }, - ], + kty: 'EC', + kid: 'kid-1-simple', }, { - issuer: { - iss: 'https://example.com/issuer2', - name: 'Example Issuer 2', - }, - keys: [ - { - kty: 'EC', - kid: 'kid-A-simple', - }, - ], + kty: 'EC', + kid: 'kid-2-simple', }, + ], + crls: [ { - issuer: { - iss: 'https://example.com/issuer3', - name: 'Example Issuer 3', - }, - keys: [ - { - kty: 'EC', - kid: 'kid-C-simple', - }, - ], + kid: 'kid-2-simple', + method: 'rid', + ctr: 1, + rids: ['revoked-1'], }, + ], + }, + { + issuer: { + iss: 'https://example.com/issuer2', + name: 'Example Issuer 2', + }, + keys: [ { - issuer: { - iss: 'https://example.com/issuer4', - name: 'Example Issuer 4', - website: 'https://example.com/issuer4', - }, - keys: [ - { - kty: 'EC', - kid: 'kid-D-simple', - }, - ], - crls: [ - { - kid: 'kid-D-simple', - method: 'rid', - ctr: 1, - rids: ['revoked-2'], - }, - ], + kty: 'EC', + kid: 'kid-A-simple', }, ], - } - const directory = Directory.fromJSON(directoryJson as DirectoryJSON) - const issuers = directory.getIssuerInfo() - expect(issuers).toHaveLength(4) + }, + { + issuer: { + iss: 'https://example.com/issuer3', + name: 'Example Issuer 3', + }, + keys: [ + { + kty: 'EC', + kid: 'kid-C-simple', + }, + ], + }, + { + issuer: { + iss: 'https://example.com/issuer4', + name: 'Example Issuer 4', + website: 'https://example.com/issuer4', + }, + keys: [ + { + kty: 'EC', + kid: 'kid-D-simple', + }, + ], + crls: [ + { + kid: 'kid-D-simple', + method: 'rid', + ctr: 1, + rids: ['revoked-2'], + }, + ], + }, + ], +} + +function assertDirectoryFromSampleJson(directory: Directory) { + const issuers = directory.getIssuerInfo() + expect(issuers).toHaveLength(4) + + const issuer1 = issuers[0]! + expect(issuer1.iss).toEqual('https://example.com/issuer') + expect(issuer1.keys).toHaveLength(2) + const crls1 = issuer1.crls! + expect(crls1).toHaveLength(1) + expect(crls1[0]!.kid).toEqual('kid-2-simple') + + const issuer2 = issuers.find(i => i.iss === 'https://example.com/issuer2')! + expect(issuer2).toBeDefined() + expect(issuer2.keys).toHaveLength(1) + + const issuer3 = issuers.find(i => i.iss === 'https://example.com/issuer3')! + expect(issuer3).toBeDefined() + expect(issuer3.keys).toHaveLength(1) + + const issuer4 = issuers.find(i => i.iss === 'https://example.com/issuer4')! + expect(issuer4).toBeDefined() + expect(issuer4.keys).toHaveLength(1) + const crls4 = issuer4.crls! + expect(crls4).toHaveLength(1) +} - const issuer1 = issuers[0]! - expect(issuer1.iss).toEqual('https://example.com/issuer') - expect(issuer1.keys).toHaveLength(2) - const crls1 = issuer1.crls! - expect(crls1).toHaveLength(1) - expect(crls1[0]!.kid).toEqual('kid-2-simple') +describe('Directory', () => { + const ISS_URL = 'https://example.com/issuer' - const issuer2 = issuers.find(i => i.iss === 'https://example.com/issuer2')! - expect(issuer2).toBeDefined() - expect(issuer2.keys).toHaveLength(1) + afterEach(() => { + vi.restoreAllMocks() + }) + + it('should create a directory from the VCI snapshot', async () => { + const originalFetch = globalThis.fetch + const fetchMock = vi.fn().mockImplementation((url: string) => { + if (url.includes('vci_snapshot.json')) { + return Promise.resolve({ + ok: true, + json: async () => SAMPLE_DIRECTORY_JSON, + }) + } + + return Promise.resolve({ ok: false, status: 404 }) + }) + ;(globalThis as any).fetch = fetchMock - const issuer3 = issuers.find(i => i.iss === 'https://example.com/issuer3')! - expect(issuer3).toBeDefined() - expect(issuer3.keys).toHaveLength(1) + const directory = await Directory.fromVCI() + assertDirectoryFromSampleJson(directory) - const issuer4 = issuers.find(i => i.iss === 'https://example.com/issuer4')! - expect(issuer4).toBeDefined() - expect(issuer4.keys).toHaveLength(1) - const crls4 = issuer4.crls! - expect(crls4).toHaveLength(1) + ;(globalThis as any).fetch = originalFetch + }) + + it('should throw when VCI snapshot fetch fails', async () => { + const originalFetch = globalThis.fetch + const fetchMock = vi.fn().mockImplementation((url: string) => { + if (url.includes('vci_snapshot.json')) { + return Promise.resolve({ ok: false, status: 500 }) + } + return Promise.resolve({ ok: false, status: 404 }) + }) + ;(globalThis as any).fetch = fetchMock + + await expect(Directory.fromVCI()).rejects.toThrow( + 'Failed to fetch VCI Directory snapshot with status 500' + ) + + ;(globalThis as any).fetch = originalFetch + }) + + it('should create a directory from JSON', () => { + const directory = Directory.fromJSON(SAMPLE_DIRECTORY_JSON as DirectoryJSON) + assertDirectoryFromSampleJson(directory) }) it('should handle missing or invalid values when building directory using fromJSON', () => {