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