add some support for tickets, introspection method, minor fixes
[squeep-indieauth-helper] / test / lib / communication.js
index 9a6056db723093696e4e4cc7f72561c3c3b9b54a..c79d2613f5388485862a3eb83ef7d84304b8582c 100644 (file)
@@ -7,12 +7,12 @@ const assert = require('assert');
 const sinon = require('sinon'); // eslint-disable-line node/no-unpublished-require
 
 const Communication = require('../../lib/communication');
+const { ValidationError } = require('../../lib/errors');
+const dns = require('dns');
 
 const stubLogger = require('../stub-logger');
 const testData = require('../test-data/communication');
 
-const noExpectedException = 'did not get expected exception';
-
 describe('Communication', function () {
   let communication, options;
 
@@ -67,12 +67,7 @@ describe('Communication', function () {
       assert.strictEqual(result.codeChallengeMethod, 'S256');
     });
     it('covers error', async function () {
-      try {
-        await Communication.generatePKCE(1);
-        assert.fail(noExpectedException);
-      } catch (e) {
-        assert(e instanceof RangeError);
-      }
+      await assert.rejects(() => Communication.generatePKCE(1));
     });
   }); // generatePKCE
 
@@ -102,12 +97,7 @@ describe('Communication', 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'));
-      }
+      assert.throws(() => Communication.verifyChallenge(challenge, verifier, method));
     });
   }); // verifyChallenge
 
@@ -160,6 +150,7 @@ describe('Communication', function () {
         },
         params: expectedUrlObj.searchParams,
         responseType: 'text',
+        validateStatus: Communication._validateStatus,
       };
       const result = Communication._axiosConfig(method, urlObj, body, params, {
         'Content-Type': contentType,
@@ -177,6 +168,7 @@ describe('Communication', function () {
         headers: {},
         params: expectedUrlObj.searchParams,
         responseType: 'text',
+        validateStatus: Communication._validateStatus,
       };
       const result = Communication._axiosConfig(method, urlObj);
       delete result.transformResponse;
@@ -194,17 +186,32 @@ describe('Communication', function () {
         headers: {},
         params: urlObj.searchParams,
         responseType: 'text',
+        validateStatus: Communication._validateStatus,
       };
       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]();
     });
+
+    describe('_validateStatus', function () {
+      it('allows normal valid', function () {
+        const result = Communication._validateStatus(200);
+        assert.strictEqual(result, true);
+      });
+      it('allows unauthorized', function () {
+        const result = Communication._validateStatus(401);
+        assert.strictEqual(result, true);
+      });
+      it('rejects invalid', function () {
+        const result = Communication._validateStatus(400);
+        assert.strictEqual(result, false);
+      });
+    }); // _validateStatus
   }); // Axios Configurations
 
   describe('_baseUrlString', function () {
@@ -477,6 +484,154 @@ describe('Communication', function () {
     });
   }); // fetchMicroformat
 
+  describe('fetchJSON', 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 () {
+      communication.axios.resolves(response);
+      expected = { foo: 'bar', baz: 123 };
+      response.data = JSON.stringify(expected);
+
+      result = await communication.fetchJSON(urlObj);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('covers axios error', async function () {
+      communication.axios.rejects(new Error('blah'));
+      expected = undefined;
+
+      result = await communication.fetchJSON(urlObj);
+
+      assert.deepStrictEqual(result, expected);
+    });
+    it('covers non-parsable content', async function () {
+      response.data = 'some bare text';
+      response.headers = {};
+      communication.axios.resolves(response);
+      expected = undefined;
+
+      result = await communication.fetchJSON(urlObj);
+
+      assert.deepStrictEqual(result, expected);
+    });
+  }); // fetchJSON
+
+  describe('validateProfile', function () {
+    let url, validationOptions;
+    beforeEach(function () {
+      url = 'https://example.com/';
+      validationOptions = {};
+      sinon.stub(dns, 'lookupAsync').resolves([{ family: 4, address: '10.11.12.14' }]);
+    });
+    it('rejects invalid url', async function () {
+      url = 'bad url';
+      await assert.rejects(() => communication.validateProfile(url, validationOptions), ValidationError);
+    });
+    it('covers success', async function () {
+      const result = await communication.validateProfile(url, validationOptions);
+      assert.strictEqual(result.isLoopback, false);
+    });
+    it('rejects invalid', async function () {
+      url = 'ftp://example.com/';
+      await assert.rejects(() => communication.validateProfile(url, validationOptions), ValidationError);
+    });
+
+  }); // validateProfile
+
+  describe('validateClientIdentifier', function () {
+    let url, validationOptions;
+    beforeEach(function () {
+      url = 'https://example.com/';
+      validationOptions = {};
+      sinon.stub(dns, 'lookupAsync').resolves([{ family: 4, address: '10.11.12.13' }]);
+    });
+    it('rejects invalid url', async function () {
+      await assert.rejects(() => communication.validateClientIdentifier('bad url'), ValidationError);
+    });
+    it('rejects invalid scheme', async function () {
+      url = 'ftp://example.com/';
+      await assert.rejects(() => communication.validateClientIdentifier(url, validationOptions), ValidationError);
+    });
+    it('rejects fragment', async function () {
+      url = 'https://example.com/#foo';
+      await assert.rejects(() => communication.validateClientIdentifier(url, validationOptions), ValidationError);
+    });
+    it('rejects username', async function () {
+      url = 'https://user@example.com/';
+      await assert.rejects(() => communication.validateClientIdentifier(url, validationOptions), ValidationError);
+    });
+    it('rejects password', async function () {
+      url = 'https://:foo@example.com/';
+      await assert.rejects(() => communication.validateClientIdentifier(url, validationOptions), ValidationError);
+    });
+    it('rejects relative path', async function () {
+      url = 'https://example.com/client/../sneaky';
+      await assert.rejects(() => communication.validateClientIdentifier(url, validationOptions), ValidationError);
+    });
+    it('rejects ipv4', async function () {
+      url = 'https://10.11.12.13/';
+      await assert.rejects(() => communication.validateClientIdentifier(url, validationOptions), ValidationError);
+    });
+    it('rejects ipv6', async function () {
+      url = 'https://[fd64:defa:00e5:caf4:0dff::ad39]/';
+      await assert.rejects(() => communication.validateClientIdentifier(url, validationOptions), ValidationError);
+    });
+    it('accepts ipv4 loopback', async function () {
+      url = 'https://127.0.0.1/';
+      const result = await communication.validateClientIdentifier(url, validationOptions);
+      assert.strictEqual(result.isLoopback, true);
+    });
+    it('accepts ipv6 loopback', async function () {
+      url = 'https://[::1]/';
+      const result = await communication.validateClientIdentifier(url, validationOptions);
+      assert.strictEqual(result.isLoopback, true);
+    });
+    it('accepts resolved ipv4 loopback', async function () {
+      dns.lookupAsync.resolves([{ family: 4, address: '127.0.0.1' }]);
+      const result = await communication.validateClientIdentifier(url, validationOptions);
+      assert.strictEqual(result.isLoopback, true);
+    });
+    it('accepts resolved ipv6 loopback', async function () {
+      dns.lookupAsync.resolves([{ family: 6, address: '::1' }]);
+      const result = await communication.validateClientIdentifier(url, validationOptions);
+      assert.strictEqual(result.isLoopback, true);
+    });
+    it('covers success', async function () {
+      const result = await communication.validateClientIdentifier(url, validationOptions);
+      assert.strictEqual(result.isLoopback, false);
+    });
+    it('rejects resolution failure', async function () {
+      dns.lookupAsync.rejects(new Error('oh no'));
+      await assert.rejects(() => communication.validateClientIdentifier(url, validationOptions), ValidationError);
+    });
+    it('rejects mismatched resolutions', async function () {
+      dns.lookupAsync.onCall(1).resolves([{ family: 4, address: '10.9.8.7' }]);
+      await assert.rejects(() => communication.validateClientIdentifier(url, validationOptions), ValidationError);
+    });
+    it('ignores unknown dns family', async function () {
+      dns.lookupAsync.resolves([{ family: 5, address: '10.9.8.7' }]);
+      const result = await communication.validateClientIdentifier(url, validationOptions);
+      assert.strictEqual(result.isLoopback, false);
+    });
+    it('covers rooted hostname', async function() {
+      url = 'https://example.com./';
+      const result = await communication.validateClientIdentifier(url, validationOptions);
+      assert.strictEqual(result.isLoopback, false);
+    });
+    it('covers unresolved', async function () {
+      dns.lookupAsync.resolves();
+      const result = await communication.validateClientIdentifier(url, validationOptions);
+      assert.strictEqual(result.isLoopback, false);
+    });
+  }); // validateClientIdentifier
+
   describe('fetchClientIdentifier', function () {
     let expected, response, result, urlObj;
     beforeEach(function () {
@@ -553,6 +708,17 @@ describe('Communication', function () {
       result = await communication.fetchClientIdentifier(urlObj);
       assert.deepStrictEqual(result, expected);
     });
+    it('covers loopback', async function () {
+      sinon.spy(communication, 'fetchMicroformat');
+      urlObj.isLoopback = true;
+      expected = {
+        rels: {},
+        items: [],
+      };
+      result = await communication.fetchClientIdentifier(urlObj);
+      assert.deepStrictEqual(result, expected);
+      assert(communication.fetchMicroformat.notCalled);
+    });
   }); // fetchClientIdentifier
 
   describe('fetchProfile', function () {
@@ -565,50 +731,148 @@ describe('Communication', function () {
         headers: {},
         data: testData.hCardHtml,
       };
+      sinon.stub(communication, 'fetchJSON');
+    });
+    describe('legacy without indieauth-metadata', function () {
+      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',
+          metadata: {
+            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',
+          metadata: {
+            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,
+          metadata: {},
+        };
+        result = await communication.fetchProfile(urlObj);
+        assert.deepStrictEqual(result, expected);
+      });
     });
     it('covers', async function () {
+      response.data = testData.hCardMetadataHtml;
       communication.axios.resolves(response);
+      communication.fetchJSON.resolves({
+        'issuer': 'https://ia.squeep.com/',
+        'authorization_endpoint': 'https://ia.squeep.com/auth',
+        'token_endpoint': 'https://ia.squeep.com/token',
+        'introspection_endpoint': 'https://ia.squeep.com/introspect',
+        'introspection_endpoint_auth_methods_supported': [ '' ],
+        'revocation_endpoint': 'https://ia.squeep.com/revoke',
+        'revocation_endpoint_auth_methods_supported': [ 'none' ],
+        'scopes_supported': [ 'profile', 'email' ],
+        'service_documentation': 'https://indieauth.spec.indieweb.org/',
+        'code_challenge_methods_supported': [ 'S256', 'SHA256' ],
+        'authorization_response_iss_parameter_supported': true,
+        'userinfo_endpoint': 'https://ia.squeep.com/userinfo',
+      });
       expected = {
         name: 'Thuza',
         photo: 'https://thuza.ratfeathers.com/image.png',
         url: 'https://thuza.ratfeathers.com/',
         email: undefined,
+        metadata: {
+          authorizationEndpoint: 'https://ia.squeep.com/auth',
+          tokenEndpoint: 'https://ia.squeep.com/token',
+          issuer: 'https://ia.squeep.com/',
+          introspectionEndpoint: 'https://ia.squeep.com/introspect',
+          introspectionEndpointAuthMethodsSupported: [ '' ],
+          revocationEndpoint: 'https://ia.squeep.com/revoke',
+          revocationEndpointAuthMethodsSupported: [ 'none' ],
+          scopesSupported: [ 'profile', 'email' ],
+          serviceDocumentation: 'https://indieauth.spec.indieweb.org/',
+          codeChallengeMethodsSupported: [ 'S256', 'SHA256' ],
+          authorizationResponseIssParameterSupported: true,
+          userinfoEndpoint: 'https://ia.squeep.com/userinfo',
+        },
         authorizationEndpoint: 'https://ia.squeep.com/auth',
         tokenEndpoint: 'https://ia.squeep.com/token',
+        indieauthMetadata: 'https://ia.squeep.com/meta',
       };
+
       result = await communication.fetchProfile(urlObj);
+
       assert.deepStrictEqual(result, expected);
     });
-    it('covers multiple hCards', async function () {
-      response.data = testData.multiMF2Html;
+    it('covers metadata missing fields', async function () {
+      response.data = testData.hCardMetadataHtml;
       communication.axios.resolves(response);
+      communication.fetchJSON.resolves({
+        'issuer': 'https://ia.squeep.com/',
+      });
       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',
+        email: undefined,
+        metadata: {
+          issuer: 'https://ia.squeep.com/',
+        },
+        indieauthMetadata: 'https://ia.squeep.com/meta',
       };
+
       result = await communication.fetchProfile(urlObj);
+
       assert.deepStrictEqual(result, expected);
     });
-    it('covers failed fetch', async function () {
-      communication.axios.rejects();
+    it('covers metadata response failure', async function () {
+      const jsonError = new Error('oh no');
+      response.data = testData.hCardMetadataHtml;
+      communication.axios
+        .onCall(0).resolves(response)
+        .onCall(1).rejects(jsonError);
+      communication.fetchJSON.restore();
       expected = {
+        name: 'Thuza',
+        photo: 'https://thuza.ratfeathers.com/image.png',
+        url: 'https://thuza.ratfeathers.com/',
         email: undefined,
-        name: undefined,
-        photo: undefined,
-        url: undefined,
+        metadata: {},
+        indieauthMetadata: 'https://ia.squeep.com/meta',
       };
+
       result = await communication.fetchProfile(urlObj);
+
       assert.deepStrictEqual(result, expected);
     });
   }); // fetchProfile
 
   describe('redeemProfileCode', function () {
     let expected, urlObj, code, codeVerifier, clientId, redirectURI;
-    this.beforeEach(function () {
+    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('+', '-');
@@ -617,7 +881,7 @@ describe('Communication', function () {
     });
     it('covers', async function () {
       communication.axios.resolves({
-        data: '{"me":"https://profile.example.com/"}'
+        data: '{"me":"https://profile.example.com/"}',
       });
       expected = {
         me: 'https://profile.example.com/',
@@ -634,5 +898,64 @@ describe('Communication', function () {
 
       assert.strictEqual(result, undefined);
     });
-  });
+  }); // redeemProfileCode
+
+  describe('introspectToken', function () {
+    let introspectionUrlObj, authenticationHeader, token;
+    beforeEach(function () {
+      introspectionUrlObj = new URL('https://ia.example.com/introspect');
+      authenticationHeader = 'Bearer XXX';
+      token = 'xxx';
+    });
+    it('covers success active', async function () {
+      const nowEpoch = Math.ceil(Date.now() / 1000);
+      communication.axios.resolves({
+        data: JSON.stringify({
+          active: true,
+          me: 'https://profile.example.com/',
+          'client_id': 'https://app.example.com/',
+          scope: 'create profile email',
+          exp: nowEpoch + 86400,
+          iat: nowEpoch,
+        }),
+      });
+      const result = await communication.introspectToken(introspectionUrlObj, authenticationHeader, token);
+      assert.strictEqual(result.active, true);
+    });
+    it('covers success inactive', async function () {
+      communication.axios.resolves({
+        data: JSON.stringify({
+          active: false,
+        }),
+      });
+      const result = await communication.introspectToken(introspectionUrlObj, authenticationHeader, token);
+      assert.strictEqual(result.active, false);
+    });
+    it('covers failure', async function () {
+      communication.axios.resolves('what kind of response is this?');
+      await assert.rejects(() => communication.introspectToken(introspectionUrlObj, authenticationHeader, token));
+    });
+  }); // introspectToken
+
+  describe('deliverTicket', function () {
+    let ticketEndpointUrlObj, resourceUrlObj, subjectUrlObj, ticket;
+    beforeEach(function () {
+      ticketEndpointUrlObj = new URL('https://ticket.example.com/');
+      resourceUrlObj = new URL('https://resource.example.com/');
+      subjectUrlObj = new URL('https://subject.example.com/');
+      ticket = 'XXXThisIsATicketXXX';
+    });
+    it('covers success', async function () {
+      const expected = { data: 'blah', statusCode: 200 };
+      communication.axios.resolves(expected);
+      const result = await communication.deliverTicket(ticketEndpointUrlObj, resourceUrlObj, subjectUrlObj, ticket);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('covers failure', async function () {
+      const expectedException = new Error('oh no');
+      communication.axios.rejects(expectedException);
+      await assert.rejects(() => communication.deliverTicket(ticketEndpointUrlObj, resourceUrlObj, subjectUrlObj, ticket), expectedException);
+    });
+  }); // deliverTicket
+
 }); // Communication
\ No newline at end of file