add account settings page, rest of otp support, stdio credential helper, other misc
[squeep-authentication-module] / test / lib / session-manager.js
index 79abbf007e8805ab05018aa77a5aae541489c62c..f0aca6d8e339ad1329d125c340ae47dd4425dea6 100644 (file)
@@ -10,16 +10,19 @@ 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: '',
@@ -27,10 +30,14 @@ describe('SessionManager', function () {
       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);
@@ -40,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 () {
@@ -48,21 +62,28 @@ 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('_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);
@@ -184,13 +205,17 @@ describe('SessionManager', function () {
   }); // postAdminLogin
 
   describe('_otpSubmission', function () {
+    let otpState;
     beforeEach(function () {
       sinon.useFakeTimers(new Date());
-      sinon.stub(manager.mysteryBox, 'unpack').resolves({
+      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';
@@ -207,6 +232,34 @@ describe('SessionManager', function () {
       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);
@@ -215,27 +268,28 @@ describe('SessionManager', function () {
       assert(res.end.called);
     });
     it('returns false when submitted otp is invalid and too many attempts', async function () {
-      manager.mysteryBox.unpack.resolves({
-        authenticatedIdentifier: 'identifier',
-        attempt: 10,
-        epochMs: Date.now(),
-      });
+      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 () {
-      manager.mysteryBox.unpack.resolves({
-        authenticatedIdentifier: 'identifier',
-        attempt: 0,
-        epochMs: Date.now() - 99999999,
-      });
+      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);
@@ -247,6 +301,67 @@ describe('SessionManager', function () {
     });
   }); // _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';
@@ -272,7 +387,7 @@ describe('SessionManager', function () {
       assert(res.end.called);
     });
     it('returns true if valid identifier requires otp entry', async function () {
-      ctx.otpNeeded = true;
+      ctx.otpKey = '1234567890123456789012';
       const result = await manager._localUserAuth(res, ctx);
       assert.strictEqual(result, true);
       assert(manager.mysteryBox.pack.called);
@@ -292,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,
       });
@@ -457,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