From c6ddd5087ffe581bd8691ebd93f4fb6ef5ed18fe Mon Sep 17 00:00:00 2001 From: Frank Hinek <frankhinek@users.noreply.github.com> Date: Sat, 27 May 2023 13:35:01 -0400 Subject: [PATCH] Fix bug in getTechPreviewDwnEndpoints() that sometimes returned only 1 endpoint Signed-off-by: Frank Hinek <frankhinek@users.noreply.github.com> --- packages/dids/tests/did-ion.spec.ts | 37 ++--- packages/dids/tests/tech-preview.spec.ts | 68 ++++++++ packages/web5/src/web5.ts | 24 +-- packages/web5/tests/chai-plugins.d.ts | 14 ++ packages/web5/tests/tech-preview.spec.ts | 150 ++++++++++++++++++ .../web5/tests/test-utils/chai-plugins.ts | 59 +++++++ 6 files changed, 318 insertions(+), 34 deletions(-) create mode 100644 packages/dids/tests/tech-preview.spec.ts create mode 100644 packages/web5/tests/chai-plugins.d.ts create mode 100644 packages/web5/tests/tech-preview.spec.ts create mode 100644 packages/web5/tests/test-utils/chai-plugins.ts diff --git a/packages/dids/tests/did-ion.spec.ts b/packages/dids/tests/did-ion.spec.ts index 6c83225c0..d60801ef4 100644 --- a/packages/dids/tests/did-ion.spec.ts +++ b/packages/dids/tests/did-ion.spec.ts @@ -5,30 +5,21 @@ import { DidIonApi } from '../src/did-ion.js'; const DidIon = new DidIonApi(); describe('DidIonApi', () => { - it('works', async () => { - const didState = await DidIon.create(); + describe('create()', () => { + it('returns a valid didState', async () => { + const didState = await DidIon.create(); - expect(didState.id).to.exist; - expect(didState.internalId).to.exist; - expect(didState.keys).to.exist; + expect(didState.id).to.exist; + expect(didState.internalId).to.exist; + expect(didState.keys).to.exist; - for (let key of didState.keys) { - expect(key.id).to.exist; - expect(key.controller).to.exist; - expect(key.publicKeyJwk).to.exist; - expect(key.privateKeyJwk).to.exist; - expect(key.type).to.exist; - } - }); - - it('works when using dwn configuration', async () => { - const ionCreateOptions = await DidIonApi.generateDwnConfiguration(['https://dwn.tbddev.org/dwn0']); - - try { - // TODO: write specific assertions - const _didState = await DidIon.create(ionCreateOptions); - } catch(e) { - expect.fail(e.message); - } + for (let key of didState.keys) { + expect(key.id).to.exist; + expect(key.controller).to.exist; + expect(key.publicKeyJwk).to.exist; + expect(key.privateKeyJwk).to.exist; + expect(key.type).to.exist; + } + }); }); }); \ No newline at end of file diff --git a/packages/dids/tests/tech-preview.spec.ts b/packages/dids/tests/tech-preview.spec.ts new file mode 100644 index 000000000..3203a91d6 --- /dev/null +++ b/packages/dids/tests/tech-preview.spec.ts @@ -0,0 +1,68 @@ +import type { DwnServiceEndpoint } from '../src/types.js'; + +import { expect } from 'chai'; + +import { DidIonApi } from '../src/did-ion.js'; + +describe('Tech Preview', function () { + describe('generateDwnConfiguration()', () => { + it.only('returns keys and services with two DWN URLs', async () => { + const ionCreateOptions = await DidIonApi.generateDwnConfiguration([ + 'https://dwn.tbddev.test/dwn0', + 'https://dwn.tbddev.test/dwn1' + ]); + + expect(ionCreateOptions).to.have.property('keys'); + expect(ionCreateOptions.keys).to.have.lengthOf(2); + let encryptionKey = ionCreateOptions.keys!.find(key => key.id === 'enc'); + expect(encryptionKey).to.exist; + let authorizationKey = ionCreateOptions.keys!.find(key => key.id === 'authz'); + expect(authorizationKey).to.exist; + + expect(ionCreateOptions).to.have.property('services'); + expect(ionCreateOptions.services).to.have.lengthOf(1); + + const [ service ] = ionCreateOptions.services!; + expect(service.id).to.equal('dwn'); + expect(service).to.have.property('serviceEndpoint'); + + const serviceEndpoint = service.serviceEndpoint as DwnServiceEndpoint; + expect(serviceEndpoint).to.have.property('nodes'); + expect(serviceEndpoint.nodes).to.have.lengthOf(2); + expect(serviceEndpoint).to.have.property('messageAuthorizationKeys'); + expect(serviceEndpoint!.messageAuthorizationKeys![0]).to.equal(`#${authorizationKey!.id}`); + expect(serviceEndpoint).to.have.property('recordEncryptionKeys'); + expect(serviceEndpoint!.recordEncryptionKeys![0]).to.equal(`#${encryptionKey!.id}`); + }); + + it.only('returns keys and services with one DWN URLs', async () => { + const ionCreateOptions = await DidIonApi.generateDwnConfiguration([ + 'https://dwn.tbddev.test/dwn0' + ]); + + const [ service ] = ionCreateOptions.services!; + expect(service.id).to.equal('dwn'); + expect(service).to.have.property('serviceEndpoint'); + + const serviceEndpoint = service.serviceEndpoint as DwnServiceEndpoint; + expect(serviceEndpoint).to.have.property('nodes'); + expect(serviceEndpoint.nodes).to.have.lengthOf(1); + expect(serviceEndpoint).to.have.property('messageAuthorizationKeys'); + expect(serviceEndpoint).to.have.property('recordEncryptionKeys'); + }); + + it.only('returns keys and services with 0 DWN URLs', async () => { + const ionCreateOptions = await DidIonApi.generateDwnConfiguration([]); + + const [ service ] = ionCreateOptions.services!; + expect(service.id).to.equal('dwn'); + expect(service).to.have.property('serviceEndpoint'); + + const serviceEndpoint = service.serviceEndpoint as DwnServiceEndpoint; + expect(serviceEndpoint).to.have.property('nodes'); + expect(serviceEndpoint.nodes).to.have.lengthOf(0); + expect(serviceEndpoint).to.have.property('messageAuthorizationKeys'); + expect(serviceEndpoint).to.have.property('recordEncryptionKeys'); + }); + }); +}); \ No newline at end of file diff --git a/packages/web5/src/web5.ts b/packages/web5/src/web5.ts index 933402134..33b0a85cd 100644 --- a/packages/web5/src/web5.ts +++ b/packages/web5/src/web5.ts @@ -83,13 +83,9 @@ export class Web5 { if (!profile) { const dwnUrls = options.techPreview?.dwnEndpoints || await Web5.getTechPreviewDwnEndpoints(); - let ionCreateOptions; - - if (dwnUrls.length > 0) { - ionCreateOptions = await DidIonApi.generateDwnConfiguration(dwnUrls); - } - + const ionCreateOptions = await DidIonApi.generateDwnConfiguration(dwnUrls); const defaultProfileDid = await this.did.create('ion', ionCreateOptions); + // setting id & name as the app's did to make migration easier profile = await profileApi.createProfile({ name : appDidState.id, @@ -122,15 +118,19 @@ export class Web5 { static async getTechPreviewDwnEndpoints(): Promise<string[]> { const response = await fetch('https://dwn.tbddev.org/.well-known/did.json'); + // Return an empty array if dwn.tbddev.org is not responding. + if (!response.ok) { return []; } + const didDoc = await response.json(); const [ service ] = didUtils.getServices(didDoc, { id: '#dwn', type: 'DecentralizedWebNode' }); const { nodes } = <DwnServiceEndpoint>service.serviceEndpoint; // allocate up to 2 nodes for a user. - const numNodesToAllocate = Math.min(Math.floor(nodes.length / 2), 2); - const dwnUrls = new Set([]); + const dwnUrls = new Set<string>(); + let attempts = 0; + const numNodesToAllocate = Math.min(nodes.length, 2); - for (let i = 0; i < numNodesToAllocate; i += 1) { + while(dwnUrls.size < numNodesToAllocate && attempts < nodes.length) { const nodeIdx = getRandomInt(0, nodes.length); const dwnUrl = nodes[nodeIdx]; @@ -139,9 +139,11 @@ export class Web5 { if (healthCheck.status === 200) { dwnUrls.add(dwnUrl); } - } catch(e) { - // ignore healthcheck failures and try the next node. + } catch(e: unknown) { + // Ignore healthcheck failures and try the next node. } + + attempts++; } return Array.from(dwnUrls); diff --git a/packages/web5/tests/chai-plugins.d.ts b/packages/web5/tests/chai-plugins.d.ts new file mode 100644 index 000000000..8dbcb2354 --- /dev/null +++ b/packages/web5/tests/chai-plugins.d.ts @@ -0,0 +1,14 @@ +/// <reference types="chai" /> + +declare namespace Chai { + + // For BDD API + interface Assertion extends LanguageChains, NumericComparison, TypeComparison { + url: Assertion; + } + + // For Assert API + interface AssertStatic { + isUrl: (actual: any) => Assertion; + } +} \ No newline at end of file diff --git a/packages/web5/tests/tech-preview.spec.ts b/packages/web5/tests/tech-preview.spec.ts new file mode 100644 index 000000000..2a1e5239f --- /dev/null +++ b/packages/web5/tests/tech-preview.spec.ts @@ -0,0 +1,150 @@ +import sinon from 'sinon'; +import chai, { expect } from 'chai'; + +import { chaiUrl } from './test-utils/chai-plugins.js'; +import { Web5 } from '../src/web5.js'; + +chai.use(chaiUrl); + +describe('Tech Preview', () => { + + describe('web5.getTechPreviewDwnEndpoints()', () => { + + let fetchStub: sinon.SinonStub; + + let mockDwnEndpoints: Array<string>; + + let tbdWellKnownOkResponse = { + status : 200, + ok : true, + json : async () => Promise.resolve({ + id : 'did:web:dwn.tbddev.org', + service : [ + { + id : '#dwn', + serviceEndpoint : { + nodes: mockDwnEndpoints + }, + type: 'DecentralizedWebNode' + } + ] + }) + }; + + let tbdWellKnownBadResponse = { + status : 400, + ok : false + }; + + let dwnServerHealthOkResponse = { + status : 200, + ok : true, + json : async () => Promise.resolve({ok: true}) + }; + + let dwnServerHealthBadResponse = { + status : 400, + ok : false + }; + + beforeEach(() => { + mockDwnEndpoints = [ + 'https://dwn.tbddev.test/dwn0', + 'https://dwn.tbddev.test/dwn1', + 'https://dwn.tbddev.test/dwn2', + 'https://dwn.tbddev.test/dwn3', + 'https://dwn.tbddev.test/dwn4', + 'https://dwn.tbddev.test/dwn5', + 'https://dwn.tbddev.test/dwn6' + ]; + + fetchStub = sinon.stub(globalThis as any, 'fetch'); + + fetchStub.callsFake((url) => { + if (url === 'https://dwn.tbddev.org/.well-known/did.json') { + return Promise.resolve(tbdWellKnownOkResponse); + } else if (url.endsWith('/health')) { + return Promise.resolve(dwnServerHealthOkResponse); + } + }); + }); + + afterEach(() => { + fetchStub.restore(); + }); + + it('returns an array', async () => { + const dwnEndpoints = await Web5.getTechPreviewDwnEndpoints(); + + expect(dwnEndpoints).to.be.an('array'); + }); + + it('returns valid DWN endpoint URLs', async () => { + const dwnEndpoints = await Web5.getTechPreviewDwnEndpoints(); + + // There must be at one URL to check or else this test always passes. + expect(dwnEndpoints).to.have.length.greaterThan(0); + + dwnEndpoints.forEach(endpoint => { + expect(endpoint).to.be.a.url; + expect(mockDwnEndpoints).to.include(endpoint); + }); + }); + + it('returns 2 DWN endpoints if at least 2 are healthy', async function() { + const promises = Array(50).fill(0).map(() => Web5.getTechPreviewDwnEndpoints()); + + const results = await Promise.all(promises); + + results.forEach(result => { + expect(result).to.be.an('array').that.has.lengthOf(2); + }); + }); + + it('returns 1 DWN endpoints if only 1 is healthy', async function() { + mockDwnEndpoints = [ + 'https://dwn.tbddev.test/dwn0' + ]; + + const promises = Array(50).fill(0).map(() => Web5.getTechPreviewDwnEndpoints()); + + const results = await Promise.all(promises); + + results.forEach(result => { + expect(result).to.be.an('array').that.has.lengthOf(1); + }); + }); + + it('returns 0 DWN endpoints if none are healthy', async function() { + // Stub fetch to simulate dwn.tbddev.org responding but all of the hosted DWN Server reporting not healthy. + fetchStub.restore(); + fetchStub = sinon.stub(globalThis as any, 'fetch'); + fetchStub.callsFake((url) => { + if (url === 'https://dwn.tbddev.org/.well-known/did.json') { + return Promise.resolve(tbdWellKnownOkResponse); + } else if (url.endsWith('/health')) { + return Promise.resolve(dwnServerHealthBadResponse); + } + }); + + const dwnEndpoints = await Web5.getTechPreviewDwnEndpoints(); + + expect(dwnEndpoints).to.be.an('array').that.has.lengthOf(0); + }); + + it('returns 0 DWN endpoints if dwn.tbddev.org is not responding', async function() { + // Stub fetch to simulate dwn.tbddev.org responding but all of the hosted DWN Server reporting not healthy. + fetchStub.restore(); + fetchStub = sinon.stub(globalThis as any, 'fetch'); + fetchStub.callsFake((url) => { + if (url === 'https://dwn.tbddev.org/.well-known/did.json') { + return Promise.resolve(tbdWellKnownBadResponse); + } + }); + + const dwnEndpoints = await Web5.getTechPreviewDwnEndpoints(); + + expect(dwnEndpoints).to.be.an('array').that.has.lengthOf(0); + }); + }); +}); diff --git a/packages/web5/tests/test-utils/chai-plugins.ts b/packages/web5/tests/test-utils/chai-plugins.ts new file mode 100644 index 000000000..5f0c9fc21 --- /dev/null +++ b/packages/web5/tests/test-utils/chai-plugins.ts @@ -0,0 +1,59 @@ +/** + * Chai plugin for validating URLs. + * + * This function adds two types of URL validation methods to Chai: + * 1. For the BDD "expect" API: `expect(string).to.be.a.url;` + * 2. For the Assert API: `assert.isUrl(string);` + * + * @param {Chai.ChaiStatic} chai - The Chai library object. + * @param {Chai.ChaiUtils} utils - The Chai Utilities object. + * + * @example + * // BDD API example: + * import chai, { expect } from 'chai'; + * import chaiUrl from './chai-plugins.js'; + * chai.use(chaiUrl); + * + * describe('My Test Suite', () => { + * it('should validate the URL', () => { + * const url = 'https://example.org'; + * expect(url).to.be.a.url; + * }); + * }); + * + * @example + * // Assert API example: + * import chai, { assert } from 'chai'; + * import chaiUrl from './chai-plugins.js'; + * chai.use(chaiUrl); + * + * describe('My Test Suite', () => { + * it('should validate the URL', () => { + * const url = 'https://example.org'; + * assert.isUrl(url); + * }); + * }); + */ +export const chaiUrl: Chai.ChaiPlugin = function(chai: Chai.ChaiStatic, utils: Chai.ChaiUtils) { + const assert = chai.assert; + function isValidUrl() { + const obj = utils.flag(this, 'object') as string; + let isUrl = true; + try { + new URL(obj); + } catch (err) { + isUrl = false; + } + this.assert( + isUrl, + 'expected #{this} to be a valid URL', + 'expected #{this} not to be a valid URL' + ); + } + + // Add the property to the BDD "expect" API. + utils.addProperty(chai.Assertion.prototype, 'url', isValidUrl); + + // Add the method to the Assert API. + assert.isUrl = (actual) => (new chai.Assertion(actual)).to.be.a.url; +}; \ No newline at end of file