initial commit
[squeep-indieauth-helper] / test / lib / communication.js
diff --git a/test/lib/communication.js b/test/lib/communication.js
new file mode 100644 (file)
index 0000000..9a6056d
--- /dev/null
@@ -0,0 +1,638 @@
+/* eslint-env mocha */
+/* eslint-disable capitalized-comments, sonarjs/no-duplicate-string */
+
+'use strict';
+
+const assert = require('assert');
+const sinon = require('sinon'); // eslint-disable-line node/no-unpublished-require
+
+const Communication = require('../../lib/communication');
+
+const stubLogger = require('../stub-logger');
+const testData = require('../test-data/communication');
+
+const noExpectedException = 'did not get expected exception';
+
+describe('Communication', function () {
+  let communication, options;
+
+  beforeEach(function () {
+    options = {};
+    communication = new Communication(stubLogger, options);
+    stubLogger._reset();
+    sinon.stub(communication, 'axios');
+  });
+  afterEach(function () {
+    sinon.restore();
+  });
+
+  it('instantiates', function () {
+    assert(communication);
+  });
+
+  it('covers no config', function () {
+    communication = new Communication(stubLogger);
+  });
+
+  describe('Axios timing coverage', function () {
+    const request = {};
+    const response = {
+      config: request,
+    };
+    it('tags request', function () {
+      communication.axios.interceptors.request.handlers[0].fulfilled(request);
+      assert(request.startTimestampMs);
+    });
+    it('tags response', function () {
+      communication.axios.interceptors.response.handlers[0].fulfilled(response);
+      assert(response.elapsedTimeMs);
+    });
+  }); // Axios timing coverage
+
+  describe('_challengeFromVerifier', function () {
+    it('covers', function () {
+      const verifier = 'VGhpcyBpcyBhIHNlY3JldC4u';
+      const expected = 'O5W5A-1CAnrNGp2yHZtEql6rfHere4wJmzsyow7LLiY';
+      const result = Communication._challengeFromVerifier(verifier);
+      assert.strictEqual(result, expected);
+    });
+  }); // _challengeFromVerifier
+
+  describe('generatePKCE', function () {
+    it('covers', async function () {
+      const result = await Communication.generatePKCE();
+      assert(result.codeVerifier);
+      assert(result.codeChallenge);
+      assert(result.codeChallengeMethod);
+      assert.strictEqual(result.codeChallengeMethod, 'S256');
+    });
+    it('covers error', async function () {
+      try {
+        await Communication.generatePKCE(1);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof RangeError);
+      }
+    });
+  }); // generatePKCE
+
+  describe('verifyChallenge', function () {
+    it('covers success', function () {
+      const method = 'S256';
+      const challenge = 'O5W5A-1CAnrNGp2yHZtEql6rfHere4wJmzsyow7LLiY';
+      const verifier = 'VGhpcyBpcyBhIHNlY3JldC4u';
+      const result = Communication.verifyChallenge(challenge, verifier, method);
+      assert.strictEqual(result, true);
+    });
+    it('also covers success', function () {
+      const method = 'SHA256';
+      const challenge = 'O5W5A-1CAnrNGp2yHZtEql6rfHere4wJmzsyow7LLiY';
+      const verifier = 'VGhpcyBpcyBhIHNlY3JldC4u';
+      const result = Communication.verifyChallenge(challenge, verifier, method);
+      assert.strictEqual(result, true);
+    });
+    it('covers failure', function () {
+      const method = 'S256';
+      const challenge = 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx';
+      const verifier = 'VGhpcyBpcyBhIHNlY3JldC4u';
+      const result = Communication.verifyChallenge(challenge, verifier, method);
+      assert.strictEqual(result, false);
+    });
+    it('covers unhandled method', function () {
+      const method = 'MD5';
+      const challenge = 'xkfP7DUYDsnu07Kg6ogc8A';
+      const verifier = 'VGhpcyBpcyBhIHNlY3JldC4u';
+      try {
+        Communication.verifyChallenge(challenge, verifier, method);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e.message.includes('unsupported'));
+      }
+    });
+  }); // verifyChallenge
+
+  describe('_userAgentString', function () {
+    it('has default behavior', function () {
+      const result = Communication._userAgentString();
+      assert(result);
+      assert(result.length > 30);
+    });
+    it('is settable', function () {
+      const result = Communication._userAgentString({
+        product: 'myClient',
+        version: '9.9.9',
+        implementation: 'custom',
+      });
+      assert(result);
+      assert.strictEqual(result, 'myClient/9.9.9 (custom)');
+    });
+    it('covers branches', function () {
+      const result = Communication._userAgentString({
+        product: 'myClient',
+        version: '9.9.9',
+        implementation: '',
+      });
+      assert(result);
+      assert.strictEqual(result, 'myClient/9.9.9');
+    });
+  }); // userAgentString
+
+  describe('Axios Configurations', function () {
+    let requestUrl, expectedUrl;
+    beforeEach(function () {
+      requestUrl = 'https://example.com/client_id';
+      expectedUrl = 'https://example.com/client_id';
+    });
+    it('_axiosConfig', function () {
+      const method = 'GET';
+      const contentType = 'text/plain';
+      const body = undefined;
+      const params = {
+        'extra_parameter': 'foobar',
+      };
+      const urlObj = new URL(requestUrl);
+      const expectedUrlObj = new URL(`${requestUrl}?extra_parameter=foobar`);
+      const expected = {
+        method,
+        url: 'https://example.com/client_id',
+        headers: {
+          'Content-Type': 'text/plain',
+        },
+        params: expectedUrlObj.searchParams,
+        responseType: 'text',
+      };
+      const result = Communication._axiosConfig(method, urlObj, body, params, {
+        'Content-Type': contentType,
+      });
+      delete result.transformResponse;
+      assert.deepStrictEqual(result, expected);
+    });
+    it('_axiosConfig covers defaults', function () {
+      const method = 'OPTIONS';
+      const urlObj = new URL(requestUrl);
+      const expectedUrlObj = new URL(requestUrl);
+      const expected = {
+        method,
+        url: expectedUrl,
+        headers: {},
+        params: expectedUrlObj.searchParams,
+        responseType: 'text',
+      };
+      const result = Communication._axiosConfig(method, urlObj);
+      delete result.transformResponse;
+      assert.deepStrictEqual(result, expected);
+    });
+    it('covers data', function () {
+      const method = 'POST';
+      const body = Buffer.from('some data');
+      const params = {};
+      const urlObj = new URL(requestUrl);
+      const expected = {
+        method,
+        url: 'https://example.com/client_id',
+        data: body,
+        headers: {},
+        params: urlObj.searchParams,
+        responseType: 'text',
+      };
+      const result = Communication._axiosConfig(method, urlObj, body, params, {});
+      delete result.transformResponse;
+      assert.deepStrictEqual(result, expected);
+
+    });
+    it('covers null response transform', function () {
+      const urlObj = new URL(requestUrl);
+      const result = Communication._axiosConfig('GET', urlObj, undefined, {}, {});
+      result.transformResponse[0]();
+    });
+  }); // Axios Configurations
+
+  describe('_baseUrlString', function () {
+    it('covers no path', function () {
+      const urlObj = new URL('https://example.com');
+      const expected = 'https://example.com/';
+      const result = Communication._baseUrlString(urlObj);
+      assert.strictEqual(result, expected);
+    });
+    it('covers paths', function () {
+      const urlObj = new URL('https://example.com/path/blah');
+      const expected = 'https://example.com/path/';
+      const result = Communication._baseUrlString(urlObj);
+      assert.strictEqual(result, expected);
+    });
+  }); // _baseUrlString
+
+  describe('_parseContentType', function () {
+    let contentTypeHeader, expected, result;
+    it('covers undefined', function () {
+      contentTypeHeader = undefined;
+      expected = {
+        mediaType: 'application/octet-stream',
+        params: {},
+      };
+      result = Communication._parseContentType(contentTypeHeader);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('covers empty', function () {
+      contentTypeHeader = '';
+      expected = {
+        mediaType: 'application/octet-stream',
+        params: {},
+      };
+      result = Communication._parseContentType(contentTypeHeader);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('covers extra parameters', function () {
+      contentTypeHeader = 'text/plain; CharSet="UTF-8"; WeirdParam';
+      expected = {
+        mediaType: 'text/plain',
+        params: {
+          'charset': 'UTF-8',
+          'weirdparam': undefined,
+        },
+      };
+      result = Communication._parseContentType(contentTypeHeader);
+      assert.deepStrictEqual(result, expected);
+    });
+  }); // parseContentType
+  
+  describe('_mergeLinkHeader', function () {
+    let microformat, response, expected;
+    beforeEach(function () {
+      microformat = {};
+      response = {
+        headers: {
+          link: '<https://example.com/>; rel="self", <https://hub.example.com/>;rel="hub"',
+        },
+        data: {},
+      }
+    });
+    it('covers', function () {
+      expected = {
+        items: [],
+        rels: {
+          'hub': ['https://hub.example.com/'],
+          'self': ['https://example.com/'],
+        },
+        'rel-urls': {
+          'https://example.com/': {
+            rels: ['self'],
+            text: '',
+          },
+          'https://hub.example.com/': {
+            rels: ['hub'],
+            text: '',
+          },
+        },
+      };
+      communication._mergeLinkHeader(microformat, response);
+      assert.deepStrictEqual(microformat, expected);
+    });
+    it('covers existing', function () {
+      microformat = {
+        items: [],
+        rels: {
+          'preload': ['https://example.com/style'],
+          'hub': ['https://hub.example.com/'],
+        },
+        'rel-urls': {
+          'https://hub.example.com/': {
+            rels: ['hub'],
+            text: '',
+          },
+          'https://example.com/style': {
+            rels: ['preload'],
+            text: '',
+          },
+        },
+      };
+      expected = {
+        items: [],
+        rels: {
+          'preload': ['https://example.com/style'],
+          'hub': ['https://hub.example.com/', 'https://hub.example.com/'],
+          'self': ['https://example.com/'],
+        },
+        'rel-urls': {
+          'https://example.com/': {
+            rels: ['self'],
+            text: '',
+          },
+          'https://hub.example.com/': {
+            rels: ['hub', 'hub'],
+            text: '',
+          },
+          'https://example.com/style': {
+            rels: ['preload'],
+            text: '',
+          },
+        },
+      };
+      communication._mergeLinkHeader(microformat, response);
+      assert.deepStrictEqual(microformat, expected);
+    });
+    it('ignores bad header', function () {
+      response.headers.link = 'not really a link header';
+      expected = {
+        items: [],
+        rels: {},
+        'rel-urls': {},
+      };
+      communication._mergeLinkHeader(microformat, response);
+      assert.deepStrictEqual(microformat, expected);
+    });
+  }); // _mergeLinkHeader
+
+  describe('fetchMicroformat', function () {
+    let expected, response, result, urlObj;
+    beforeEach(function () {
+      expected = undefined;
+      result = undefined;
+      urlObj = new URL('https://thuza.ratfeathers.com/');
+      response = {
+        headers: Object.assign({}, testData.linkHeaders),
+        data: testData.hCardHtml,
+      };
+    });
+    it('covers', async function () {
+      response.data = testData.hCardHtml;
+      communication.axios.resolves(response);
+      expected = {
+        rels: {
+          'authorization_endpoint': ['https://ia.squeep.com/auth'],
+          'token_endpoint': ['https://ia.squeep.com/token'],
+          'canonical': ['https://thuza.ratfeathers.com/'],
+          'author': ['https://thuza.ratfeathers.com/'],
+          'me': ['https://thuza.ratfeathers.com/'],
+          'self': ['https://thuza.ratfeathers.com/'],
+          'hub': ['https://hub.squeep.com/'],
+          'preload': ['https://thuza.ratfeathers.com/image.png'],
+        },
+        'rel-urls': {
+          'https://hub.squeep.com/': {
+            rels: ['hub'],
+            text: '',
+          },
+          'https://ia.squeep.com/auth': {
+            rels: ['authorization_endpoint'],
+            text: '',
+          },
+          'https://ia.squeep.com/token': {
+            rels: ['token_endpoint'],
+            text: '',
+          },
+          'https://thuza.ratfeathers.com/': {
+            rels: ['self', 'canonical', 'author', 'me'],
+            text: 'Thuza',
+          },
+          'https://thuza.ratfeathers.com/image.png': {
+            rels: ['preload'],
+            text: '',
+          },
+        },
+        items: [{
+          properties: {
+            name: ['Thuza'],
+            photo: ['https://thuza.ratfeathers.com/image.png'],
+            url: ['https://thuza.ratfeathers.com/'],
+          },
+          type: ['h-card'],
+        }],
+      };
+
+      result = await communication.fetchMicroformat(urlObj);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('covers axios error', async function () {
+      communication.axios.rejects(new Error('blah'));
+      expected = undefined;
+
+      result = await communication.fetchMicroformat(urlObj);
+
+      assert.deepStrictEqual(result, expected);
+    });
+    it('covers non-parsable content', async function () {
+      response.data = 'some bare text';
+      response.headers = {};
+      communication.axios.resolves(response);
+      expected = {
+        items: [],
+        rels: {},
+        'rel-urls': {},
+      };
+
+      result = await communication.fetchMicroformat(urlObj);
+
+      assert.deepStrictEqual(result, expected);
+    });
+    it('covers non-utf8 content', async function () {
+      response.headers['content-type'] = 'text/html; charset=ASCII';
+      communication.axios.resolves(response);
+      expected = {
+        rels: {
+          'authorization_endpoint': ['https://ia.squeep.com/auth'],
+          'token_endpoint': ['https://ia.squeep.com/token'],
+          'canonical': ['https://thuza.ratfeathers.com/'],
+          'author': ['https://thuza.ratfeathers.com/'],
+          'me': ['https://thuza.ratfeathers.com/'],
+          'self': ['https://thuza.ratfeathers.com/'],
+          'hub': ['https://hub.squeep.com/'],
+          'preload': ['https://thuza.ratfeathers.com/image.png'],
+        },
+        'rel-urls': {
+          'https://hub.squeep.com/': {
+            rels: ['hub'],
+            text: '',
+          },
+          'https://ia.squeep.com/auth': {
+            rels: ['authorization_endpoint'],
+            text: '',
+          },
+          'https://ia.squeep.com/token': {
+            rels: ['token_endpoint'],
+            text: '',
+          },
+          'https://thuza.ratfeathers.com/': {
+            rels: ['self', 'canonical', 'author', 'me'],
+            text: 'Thuza',
+          },
+          'https://thuza.ratfeathers.com/image.png': {
+            rels: ['preload'],
+            text: '',
+          },
+        },
+        items: [{
+          properties: {
+            name: ['Thuza'],
+            photo: ['https://thuza.ratfeathers.com/image.png'],
+            url: ['https://thuza.ratfeathers.com/'],
+          },
+          type: ['h-card'],
+        }],
+      };
+
+      result = await communication.fetchMicroformat(urlObj);
+
+      assert.deepStrictEqual(result, expected);
+    });
+  }); // fetchMicroformat
+
+  describe('fetchClientIdentifier', function () {
+    let expected, response, result, urlObj;
+    beforeEach(function () {
+      expected = undefined;
+      result = undefined;
+      urlObj = new URL('https://thuza.ratfeathers.com/');
+      response = {
+        headers: {},
+        data: testData.multiMF2Html,
+      };
+    });
+    it('covers', async function () {
+      communication.axios.resolves(response);
+      expected = {
+        items: [{
+          properties: {
+            name: ['Also Some Client'],
+            url: ['https://thuza.ratfeathers.com/'],
+          },
+          type: ['h-app'],
+        }],
+        rels: {
+          'author': ['https://thuza.ratfeathers.com/'],
+          'authorization_endpoint': ['https://ia.squeep.com/auth'],
+          'canonical': ['https://thuza.ratfeathers.com/'],
+          'me': ['https://thuza.ratfeathers.com/'],
+          'token_endpoint': ['https://ia.squeep.com/token'],
+        },
+      };
+      result = await communication.fetchClientIdentifier(urlObj);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('covers failed fetch', async function () {
+      communication.axios.rejects();
+      expected = undefined;
+      result = await communication.fetchClientIdentifier(urlObj);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('covers no h-app data', async function () {
+      response.data = testData.noneMF2Html;
+      communication.axios.resolves(response);
+      expected = {
+        items: [],
+        rels: {},
+      };
+      result = await communication.fetchClientIdentifier(urlObj);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('covers missing fields', async function () {
+      sinon.stub(communication, 'fetchMicroformat').resolves({});
+      expected = {
+        rels: {},
+        items: [],
+      };
+      result = await communication.fetchClientIdentifier(urlObj);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('covers other missing fields', async function () {
+      sinon.stub(communication, 'fetchMicroformat').resolves({
+        items: [
+          {},
+          {
+            type: ['h-app'],
+            properties: {
+              url: ['https://example.com'],
+            },
+          },
+        ],
+      });
+      expected = {
+        rels: {},
+        items: [],
+      };
+      result = await communication.fetchClientIdentifier(urlObj);
+      assert.deepStrictEqual(result, expected);
+    });
+  }); // fetchClientIdentifier
+
+  describe('fetchProfile', function () {
+    let expected, response, result, urlObj;
+    beforeEach(function () {
+      expected = undefined;
+      result = undefined;
+      urlObj = new URL('https://thuza.ratfeathers.com/');
+      response = {
+        headers: {},
+        data: testData.hCardHtml,
+      };
+    });
+    it('covers', async function () {
+      communication.axios.resolves(response);
+      expected = {
+        name: 'Thuza',
+        photo: 'https://thuza.ratfeathers.com/image.png',
+        url: 'https://thuza.ratfeathers.com/',
+        email: undefined,
+        authorizationEndpoint: 'https://ia.squeep.com/auth',
+        tokenEndpoint: 'https://ia.squeep.com/token',
+      };
+      result = await communication.fetchProfile(urlObj);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('covers multiple hCards', async function () {
+      response.data = testData.multiMF2Html;
+      communication.axios.resolves(response);
+      expected = {
+        email: undefined,
+        name: 'Thuza',
+        photo: 'https://thuza.ratfeathers.com/image.png',
+        url: 'https://thuza.ratfeathers.com/',
+        authorizationEndpoint: 'https://ia.squeep.com/auth',
+        tokenEndpoint: 'https://ia.squeep.com/token',
+      };
+      result = await communication.fetchProfile(urlObj);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('covers failed fetch', async function () {
+      communication.axios.rejects();
+      expected = {
+        email: undefined,
+        name: undefined,
+        photo: undefined,
+        url: undefined,
+      };
+      result = await communication.fetchProfile(urlObj);
+      assert.deepStrictEqual(result, expected);
+    });
+  }); // fetchProfile
+
+  describe('redeemProfileCode', function () {
+    let expected, urlObj, code, codeVerifier, clientId, redirectURI;
+    this.beforeEach(function () {
+      urlObj = new URL('https://example.com/auth');
+      code = Buffer.allocUnsafe(42).toString('base64').replace('/', '_').replace('+', '-');
+      codeVerifier = Buffer.allocUnsafe(42).toString('base64').replace('/', '_').replace('+', '-');
+      clientId = 'https://example.com/';
+      redirectURI = 'https://example.com/_ia';
+    });
+    it('covers', async function () {
+      communication.axios.resolves({
+        data: '{"me":"https://profile.example.com/"}'
+      });
+      expected = {
+        me: 'https://profile.example.com/',
+      };
+
+      const result = await communication.redeemProfileCode(urlObj, code, codeVerifier, clientId, redirectURI);
+
+      assert.deepStrictEqual(result, expected);
+    });
+    it('covers failure', async function () {
+      communication.axios.resolves('Not a JSON payload.');
+
+      const result = await communication.redeemProfileCode(urlObj, code, codeVerifier, clientId, redirectURI);
+
+      assert.strictEqual(result, undefined);
+    });
+  });
+}); // Communication
\ No newline at end of file