add account settings page, rest of otp support, stdio credential helper, other misc
[squeep-authentication-module] / test / lib / session-manager.js
index a3efeafce3b812fcfd3961393e4e015a5986afad..f0aca6d8e339ad1329d125c340ae47dd4425dea6 100644 (file)
@@ -7,27 +7,37 @@ const assert = require('assert');
 const sinon = require('sinon'); // eslint-disable-line node/no-unpublished-require
 
 const SessionManager = require('../../lib/session-manager');
+const Enum = require('../../lib/enum');
 const Config = require('../stub-config');
 const stubLogger = require('../stub-logger');
+const stubDb = require('../stub-db');
 
 describe('SessionManager', function () {
   let manager, options, stubAuthenticator;
   let res, ctx;
 
   beforeEach(function () {
+    stubDb._reset();
     options = new Config('test');
     res = {
       end: sinon.stub(),
       setHeader: sinon.stub(),
+      appendHeader: sinon.stub(),
     };
     ctx = {
       cookie: '',
       params: {},
       queryParams: {},
       parsedBody: {},
+      errors: [],
+      notifications: [],
     };
     stubAuthenticator = {
       isValidIdentifierCredential: sinon.stub(),
+      checkOTP: sinon.stub(),
+      _validateAuthDataCredential: sinon.stub(),
+      updateCredential: sinon.stub(),
+      db: stubDb,
     };
     manager = new SessionManager(stubLogger, stubAuthenticator, options);
     sinon.stub(manager.indieAuthCommunication);
@@ -37,6 +47,13 @@ describe('SessionManager', function () {
     sinon.restore();
   });
 
+  describe('constructor', function () {
+    it('covers options', function () {
+      delete options.dingus.proxyPrefix;
+      manager = new SessionManager(stubLogger, stubAuthenticator, options);
+    });
+  }); // constructor
+
   describe('_sessionCookieSet', function () {
     let session, maxAge;
     beforeEach(function () {
@@ -45,28 +62,50 @@ describe('SessionManager', function () {
     });
     it('covers', async function () {
       await manager._sessionCookieSet(res, session, maxAge);
-      assert(res.setHeader.called);
+      assert(res.appendHeader.called);
     });
     it('covers reset', async function () {
       session = undefined;
       maxAge = 0;
       await manager._sessionCookieSet(res, session, maxAge);
-      assert(res.setHeader.called);
+      assert(res.appendHeader.called);
     });
     it('covers options', async function() {
       options.authenticator.secureAuthOnly = false;
-      await manager._sessionCookieSet(res, session, undefined, '');
-      assert(res.setHeader.called);
+      await manager._sessionCookieSet(res, session, 'none', '');
+      assert(res.appendHeader.called);
     });
   }); // _sessionCookieSet
 
-  describe('getAdminLogin', function () {
+  describe('_sessionCookieClear', function () {
     it('covers', async function () {
+      await manager._sessionCookieClear(res);
+      assert(res.appendHeader.called);
+    })
+  }); // _sessionCookieClear
+
+  describe('getAdminLogin', function () {
+    it('covers no session', async function () {
+      await manager.getAdminLogin(res, ctx);
+    });
+    it('covers established session', async function () {
+      ctx.authenticationId = 'identifier';
+      ctx.queryParams['r'] = '/admin';
       await manager.getAdminLogin(res, ctx);
+      assert.strictEqual(res.statusCode, 302);
+      assert(res.setHeader.called);
     });
   }); // getAdminLogin
 
   describe('postAdminLogin', function () {
+    beforeEach(function () {
+      sinon.stub(manager, '_otpSubmission').resolves(false);
+    });
+    it('covers otp submission', async function () {
+      manager._otpSubmission.resolves(true);
+      await manager.postAdminLogin(res, ctx);
+      assert(res.end.notCalled);
+    });
     it('covers valid local', async function () {
       ctx.parsedBody.identifier = 'user';
       ctx.parsedBody.credential = 'password';
@@ -115,15 +154,15 @@ describe('SessionManager', function () {
     });
     it('covers profile scheme fallback', async function () {
       ctx.parsedBody.me = 'https://example.com/profile';
-      ctx.parsedBody.me_auto_scheme = '1';
+      ctx.parsedBody['me_auto_scheme'] = '1';
       manager.indieAuthCommunication.fetchProfile
         .onCall(0).resolves()
         .onCall(1).resolves({
-        metadata: {
-          issuer: 'https://example.com/',
-          authorizationEndpoint: 'https://example.com/auth',
-        },
-      });
+          metadata: {
+            issuer: 'https://example.com/',
+            authorizationEndpoint: 'https://example.com/auth',
+          },
+        });
       await manager.postAdminLogin(res, ctx);
       assert.strictEqual(res.statusCode, 302);
 
@@ -165,6 +204,197 @@ describe('SessionManager', function () {
     }); // living-standard-20220212
   }); // postAdminLogin
 
+  describe('_otpSubmission', function () {
+    let otpState;
+    beforeEach(function () {
+      sinon.useFakeTimers(new Date());
+      otpState = {
+        authenticatedIdentifier: 'identifier',
+        key: '1234567890123456789012',
+        attempt: 0,
+        epochMs: Date.now(),
+        redirect: '/',
+      };
+      sinon.stub(manager.mysteryBox, 'unpack').resolves(otpState);
+      manager.authenticator.checkOTP.resolves(Enum.OTPResult.Valid);
+      ctx.parsedBody.state = 'state_data';
+      ctx.parsedBody.otp = '123456';
+    });
+    it('returns false if no otp state', async function () {
+      delete ctx.parsedBody.state;
+      const result = await manager._otpSubmission(res, ctx);
+      assert(manager.mysteryBox.unpack.notCalled);
+      assert.strictEqual(result, false);
+    });
+    it('returns false when presented with invalid otp state', async function () {
+      manager.mysteryBox.unpack.rejects();
+      const result = await manager._otpSubmission(res, ctx);
+      assert(manager.mysteryBox.unpack.called);
+      assert.strictEqual(result, false);
+    });
+    it('returns false when otp state missing identifier field', async function () {
+      delete otpState.authenticatedIdentifier;
+      manager.mysteryBox.unpack.resolves(otpState);
+      const result = await manager._otpSubmission(res, ctx);
+      assert(manager.mysteryBox.unpack.called);
+      assert.strictEqual(result, false);
+    });
+    it('returns false when otp state missing key field', async function () {
+      delete otpState.key;
+      manager.mysteryBox.unpack.resolves(otpState);
+      const result = await manager._otpSubmission(res, ctx);
+      assert(manager.mysteryBox.unpack.called);
+      assert.strictEqual(result, false);
+    });
+    it('returns false when otp state missing attempt field', async function () {
+      delete otpState.attempt;
+      manager.mysteryBox.unpack.resolves(otpState);
+      const result = await manager._otpSubmission(res, ctx);
+      assert(manager.mysteryBox.unpack.called);
+      assert.strictEqual(result, false);
+    });
+    it('returns false when otp state missing epoch field', async function () {
+      delete otpState.epochMs;
+      manager.mysteryBox.unpack.resolves(otpState);
+      const result = await manager._otpSubmission(res, ctx);
+      assert(manager.mysteryBox.unpack.called);
+      assert.strictEqual(result, false);
+    });
+    it('returns true when submitted otp is invalid, but allowed to retry', async function () {
+      manager.authenticator.checkOTP.resolves(Enum.OTPResult.InvalidSoftFail);
+      const result = await manager._otpSubmission(res, ctx);
+      assert(manager.mysteryBox.unpack.called);
+      assert.strictEqual(result, true);
+      assert(res.end.called);
+    });
+    it('returns false when submitted otp is invalid and too many attempts', async function () {
+      otpState.attempt = 10;
+      manager.mysteryBox.unpack.resolves(otpState);
+      manager.authenticator.checkOTP.resolves(Enum.OTPResult.InvalidHardFail);
+      const result = await manager._otpSubmission(res, ctx);
+      assert(manager.mysteryBox.unpack.called);
+      assert.strictEqual(result, false);
+    });
+    it('returns false when submitted otp is invalid and too much time has passed', async function () {
+      otpState.epochMs = Date.now() - 99999999;
+      manager.mysteryBox.unpack.resolves(otpState);
+      manager.authenticator.checkOTP.resolves(Enum.OTPResult.InvalidHardFail);
+      const result = await manager._otpSubmission(res, ctx);
+      assert(manager.mysteryBox.unpack.called);
+      assert.strictEqual(result, false);
+    });
+    it('returns true when no otp submitted', async function () {
+      ctx.parsedBody.otp = '';
+      const result = await manager._otpSubmission(res, ctx);
+      assert(manager.mysteryBox.unpack.called);
+      assert.strictEqual(result, true);
+      assert(res.end.called);
+    });
+    it('returns true when submitted otp is valid', async function () {
+      const result = await manager._otpSubmission(res, ctx);
+      assert(res.end.called);
+      assert.strictEqual(result, true);
+    });
+    it('covers unexpected otp response', async function () {
+      manager.authenticator.checkOTP.resolves('wrong');
+      assert.rejects(() => manager._otpSubmission(res, ctx), RangeError);
+    });
+  }); // _otpSubmission
+
+  describe('_validateOTPState', function () {
+    let otpState;
+    it('covers valid', function () {
+      otpState = {
+        authenticatedIdentifier: 'identifier',
+        key: '1234567890123456789012',
+        attempt: 0,
+        epochMs: Date.now(),
+        redirect: '/',
+      };
+      SessionManager._validateOTPState(otpState);
+    });
+    it('covers missing identifier', function () {
+      otpState = {
+        authenticatedIdentifier: '',
+        key: '1234567890123456789012',
+        attempt: 0,
+        epochMs: Date.now(),
+        redirect: '/',
+      };
+      assert.throws(() => SessionManager._validateOTPState(otpState));
+    });
+    it('covers missing key', function () {
+      otpState = {
+        authenticatedIdentifier: 'identifier',
+        key: '',
+        attempt: 0,
+        epochMs: Date.now(),
+        redirect: '/',
+      };
+      assert.throws(() => SessionManager._validateOTPState(otpState));
+    });
+    it('covers missing attempt', function () {
+      otpState = {
+        authenticatedIdentifier: 'identifier',
+        key: '1234567890123456789012',
+        epochMs: Date.now(),
+        redirect: '/',
+      };
+      assert.throws(() => SessionManager._validateOTPState(otpState));
+    });
+    it('covers missing epoch', function () {
+      otpState = {
+        authenticatedIdentifier: 'identifier',
+        key: '1234567890123456789012',
+        attempt: 0,
+        redirect: '/',
+      };
+      assert.throws(() => SessionManager._validateOTPState(otpState));
+    });
+    it('covers missing redirect', function () {
+      otpState = {
+        authenticatedIdentifier: 'identifier',
+        key: '1234567890123456789012',
+        attempt: 0,
+        epochMs: Date.now(),
+      };
+      assert.throws(() => SessionManager._validateOTPState(otpState));
+    });
+  }); // _validateOTPState
+
+  describe('_localUserAuth', function () {
+    beforeEach(function () {
+      ctx.parsedBody.identifier = 'identifier';
+      ctx.parsedBody.credential = 'credential';
+      manager.authenticator.isValidIdentifierCredential.resolves(true);
+      sinon.stub(manager.mysteryBox, 'pack').resolves('box');
+    });
+    it('returns false if indieauth available', async function () {
+      ctx.parsedBody.me = 'https://example.com/';
+      const result = await manager._localUserAuth(res, ctx);
+      assert.strictEqual(result, false);
+    });
+    it('returns true if identifier is invalid', async function () {
+      manager.authenticator.isValidIdentifierCredential.resolves(false);
+      const result = await manager._localUserAuth(res, ctx);
+      assert.strictEqual(result, true);
+      assert(manager.authenticator.isValidIdentifierCredential.called);
+      assert(res.end.called);
+    });
+    it('returns true if valid identifier', async function () {
+      const result = await manager._localUserAuth(res, ctx);
+      assert.strictEqual(result, true);
+      assert(res.end.called);
+    });
+    it('returns true if valid identifier requires otp entry', async function () {
+      ctx.otpKey = '1234567890123456789012';
+      const result = await manager._localUserAuth(res, ctx);
+      assert.strictEqual(result, true);
+      assert(manager.mysteryBox.pack.called);
+      assert(res.end.called);
+    });
+  }); // _localUserAuth
+
   describe('getAdminLogout', function () {
     it('covers', async function () {
       await manager.getAdminLogout(res, ctx);
@@ -177,7 +407,9 @@ describe('SessionManager', function () {
       state = '4ea7e936-3427-11ec-9f4b-0025905f714a';
       me = 'https://example.com/profile';
       authorizationEndpoint = 'https://example.com/auth'
-      ctx.cookie = 'squeepSession=sessionCookie';
+      ctx.cookie = {
+        squeepSession: 'sessionCookie',
+      };
       manager.indieAuthCommunication.redeemProfileCode.resolves({
         me,
       });
@@ -342,4 +574,205 @@ describe('SessionManager', function () {
     }); // living-standard-20220212
   }); // getAdminIA
 
-}); // SessionManager
\ No newline at end of file
+  describe('getAdminSettings', function () {
+    it('covers success', async function () {
+      manager.db.authenticationGet.resolves({});
+      await manager.getAdminSettings(res, ctx);
+      assert(!ctx.errors.length);
+    });
+    it('covers no user', async function () {
+      manager.db.authenticationGet.resolves();
+      await manager.getAdminSettings(res, ctx);
+      assert(ctx.errors.length);
+    });
+    it('covers db failure', async function () {
+      manager.db.authenticationGet.throws();
+      await manager.getAdminSettings(res, ctx);
+      assert(ctx.errors.length);
+    });
+  }); // getAdminSettings
+
+  describe('postAdminSettings', function () {
+    let authData;
+    beforeEach(function () {
+      authData = {
+        identifier: 'user',
+        credential: 'password',
+        otpKey: '12345678901234567890123456789012',
+      };
+      manager.db.authenticationGet.resolves(authData);
+      sinon.stub(manager, '_credentialUpdate');
+      sinon.stub(manager, '_otpEnable');
+      sinon.stub(manager, '_otpConfirm');
+      sinon.stub(manager, '_otpDisable');
+    }); 
+    it('covers no action', async function () {
+      await manager.postAdminSettings(res, ctx);
+      assert(!ctx.errors.length);
+    });
+    it('covers db empty', async function () {
+      manager.db.authenticationGet.resolves();
+      await manager.postAdminSettings(res, ctx);
+      assert(ctx.errors.length);
+    });
+    it('covers db error', async function () {
+      manager.db.authenticationGet.throws();
+      await manager.postAdminSettings(res, ctx);
+      assert(ctx.errors.length);
+    });
+    it('covers credential update', async function () {
+      ctx.parsedBody.credential = 'update';
+      await manager.postAdminSettings(res, ctx);
+      assert(manager._credentialUpdate.called);
+    });
+    it('covers otp enabling', async function () {
+      ctx.parsedBody.otp = 'enable';
+      await manager.postAdminSettings(res, ctx);
+      assert(manager._otpEnable.called);
+    });
+    it('covers otp confirmation', async function () {
+      ctx.parsedBody.otp = 'confirm';
+      await manager.postAdminSettings(res, ctx);
+      assert(manager._otpConfirm.called);
+    });
+    it('covers otp disabling', async function () {
+      ctx.parsedBody.otp = 'disable';
+      await manager.postAdminSettings(res, ctx);
+      assert(manager._otpDisable.called);
+    });
+  }); // postAdminSettings
+
+  describe('_otpDisable', function () {
+    let dbCtx, authData;
+    beforeEach(function () {
+      ctx.otpKey = '12345678901234567890123456789012';
+      dbCtx = {};
+      authData = {
+        otpKey: '12345678901234567890123456789012',
+      };
+    });
+    it('covers success', async function () {
+      await manager._otpDisable(dbCtx, ctx, authData);
+      assert(!ctx.otpKey);
+      assert(!authData.otpKey);
+      assert(manager.db.authenticationUpdateOTPKey.called);
+      assert(ctx.notifications.length);
+      assert(!ctx.errors.length);
+    }); 
+    it('covers db failure', async function () {
+      manager.db.authenticationUpdateOTPKey.throws();
+      await manager._otpDisable(dbCtx, ctx, authData);
+      assert(!ctx.notifications.length);
+      assert(ctx.errors.length);
+    }); 
+  }); // _otpDisable
+
+  describe('_otpEnsable', function () {
+    it('covers success', async function () {
+      await manager._otpEnable(ctx);
+      assert('otpConfirmKey' in ctx);
+      assert('otpConfirmBox' in ctx);
+      assert(!ctx.errors.length);
+    });
+    it('covers failure', async function () {
+      sinon.stub(manager.mysteryBox, 'pack').throws();
+      await manager._otpEnable(ctx);
+      assert(!('otpConfirmKey' in ctx));
+      assert(!('otpConfirmBox' in ctx));
+      assert(ctx.errors.length);
+    });
+  }); // _otpEnsable
+
+  describe('_otpConfirm', function () {
+    let dbCtx, otpState;
+    beforeEach(function () {
+      sinon.stub(Date, 'now').returns(1710435655000);
+      dbCtx = {};
+      ctx.parsedBody = {
+        'otp-box': 'xxxBoxedStatexxx',
+        'otp-token': '350876',
+      };
+      otpState = {
+        otpKey: 'CDBGB3U3B2ILECQORMINGGSZN7LXY565',
+        otpAttempt: 0,
+        otpInitiatedMs: 1710434052084,
+      };
+      sinon.stub(manager.mysteryBox, 'unpack').resolves(otpState);
+    });
+    it('covers success', async function () {
+      await manager._otpConfirm(dbCtx, ctx);
+      assert(manager.db.authenticationUpdateOTPKey.called);
+      assert(ctx.notifications.length);
+      assert(!ctx.errors.length);
+    });
+    it('covers bad state', async function () {
+      manager.mysteryBox.unpack.throws();
+      await manager._otpConfirm(dbCtx, ctx);
+      assert(ctx.errors.length);
+      assert(manager.db.authenticationUpdateOTPKey.notCalled);
+    });
+    it('covers no token entered', async function () {
+      ctx.parsedBody['otp-token'] = '';
+      await manager._otpConfirm(dbCtx, ctx);
+      assert(!ctx.errors.length);
+      assert(manager.db.authenticationUpdateOTPKey.notCalled);
+    });
+    it('covers bad token entered', async function () {
+      ctx.parsedBody['otp-token'] = '123456';
+      await manager._otpConfirm(dbCtx, ctx);
+      assert(ctx.errors.length);
+      assert(manager.db.authenticationUpdateOTPKey.notCalled);
+    });
+    it('covers db error', async function () {
+      manager.db.authenticationUpdateOTPKey.throws();
+      await manager._otpConfirm(dbCtx, ctx);
+      assert(ctx.errors.length);
+    });
+  }); // _otpConfirm
+
+  describe('_credentialUpdate', function () {
+    let dbCtx, authData;
+    beforeEach(function () {
+      ctx.parsedBody = {
+        'credential-new': 'abc',
+        'credential-new-2': 'abc',
+        'credential-current': '123',
+      };
+      authData = {};
+      manager.authenticator._validateAuthDataCredential.resolves(true);
+    });
+    it('covers success', async function () {
+      await manager._credentialUpdate(dbCtx, ctx, authData);
+      assert(ctx.notifications.length);
+      assert(!ctx.errors.length);
+    });
+    it('covers invalid current password', async function () {
+      manager.authenticator._validateAuthDataCredential.resolves(false);
+      await manager._credentialUpdate(dbCtx, ctx, authData);
+      assert(!ctx.notifications.length);
+      assert(ctx.errors.length);
+    });
+    it('covers empty new password', async function () {
+      delete ctx.parsedBody['credential-new'];
+      manager.authenticator._validateAuthDataCredential.resolves(false);
+      await manager._credentialUpdate(dbCtx, ctx, authData);
+      assert(!ctx.notifications.length);
+      assert(ctx.errors.length);
+    });
+    it('covers mismatched new password', async function () {
+      ctx.parsedBody['credential-new'] = 'cde';
+      manager.authenticator._validateAuthDataCredential.resolves(false);
+      await manager._credentialUpdate(dbCtx, ctx, authData);
+      assert(!ctx.notifications.length);
+      assert(ctx.errors.length);
+    });
+    it('covers db failure', async function () {
+      manager.authenticator.updateCredential.throws();
+      await manager._credentialUpdate(dbCtx, ctx, authData);
+      assert(!ctx.notifications.length);
+      assert(ctx.errors.length);
+      assert(manager.logger.error.called);
+    });
+  }); // _credentialUpdate
+
+}); // SessionManager