From 196769000d66eeeb028a5f082cd10b7975eac9ef Mon Sep 17 00:00:00 2001 From: Ian Fox Date: Thu, 11 May 2017 20:19:44 -0700 Subject: [PATCH] feat: Add token models and tests --- index.js | 4 +- lib/eventFactory.js | 2 +- lib/secretFactory.js | 2 +- lib/token.js | 22 +++++++ lib/tokenFactory.js | 59 +++++++++++++++++ lib/user.js | 56 ++++++++++++++++ lib/userFactory.js | 2 +- package.json | 2 +- test/lib/build.test.js | 4 ++ test/lib/token.test.js | 75 +++++++++++++++++++++ test/lib/tokenFactory.test.js | 120 ++++++++++++++++++++++++++++++++++ test/lib/user.test.js | 85 ++++++++++++++++++++++-- 12 files changed, 424 insertions(+), 9 deletions(-) create mode 100644 lib/token.js create mode 100644 lib/tokenFactory.js create mode 100644 test/lib/token.test.js create mode 100644 test/lib/tokenFactory.test.js diff --git a/index.js b/index.js index e1736420..b64e8ba2 100644 --- a/index.js +++ b/index.js @@ -7,6 +7,7 @@ const PipelineFactory = require('./lib/pipelineFactory'); const SecretFactory = require('./lib/secretFactory'); const UserFactory = require('./lib/userFactory'); const TemplateFactory = require('./lib/templateFactory'); +const TokenFactory = require('./lib/tokenFactory'); module.exports = { BuildFactory, @@ -15,5 +16,6 @@ module.exports = { PipelineFactory, SecretFactory, UserFactory, - TemplateFactory + TemplateFactory, + TokenFactory }; diff --git a/lib/eventFactory.js b/lib/eventFactory.js index 12bec55b..a99aa4f7 100644 --- a/lib/eventFactory.js +++ b/lib/eventFactory.js @@ -19,7 +19,7 @@ class EventFactory extends BaseFactory { /** * Instantiate an Event class * @method createClass - * @param config + * @param {Object} config * @return {Event} */ createClass(config) { diff --git a/lib/secretFactory.js b/lib/secretFactory.js index 624333ce..32fdda4c 100644 --- a/lib/secretFactory.js +++ b/lib/secretFactory.js @@ -51,7 +51,7 @@ class SecretFactory extends BaseFactory { /** * Instantiate a Secret class * @method createClass - * @param config + * @param {Object} config * @return {Secret} */ createClass(config) { diff --git a/lib/token.js b/lib/token.js new file mode 100644 index 00000000..160987a0 --- /dev/null +++ b/lib/token.js @@ -0,0 +1,22 @@ +'use strict'; + +const BaseModel = require('./base'); + +class TokenModel extends BaseModel { + /** + * Construct a TokenModel object + * @method constructor + * @param {Object} config + * @param {Object} config.datastore Object that will perform operations on the datastore + * @param {Number} config.userId The ID of the associated user + * @param {String} config.uuid UUID for revoking the token + * @param {String} config.name The token name + * @param {String} config.description The token description + * @param {String} config.lastUsed The last time the token was used (ISO String) + */ + constructor(config) { + super('token', config); + } +} + +module.exports = TokenModel; diff --git a/lib/tokenFactory.js b/lib/tokenFactory.js new file mode 100644 index 00000000..9957e3c2 --- /dev/null +++ b/lib/tokenFactory.js @@ -0,0 +1,59 @@ +'use strict'; + +const BaseFactory = require('./baseFactory'); +const Token = require('./token'); + +let instance; + +class TokenFactory extends BaseFactory { + /** + * Construct a TokenFactory object + * @method constructor + * @param {Object} config + * @param {Object} config.datastore Object that will perform operations on the datastore + */ + constructor(config) { + super('token', config); + } + + /** + * Instantiate a Token class + * @method createClass + * @param {Object} config + * @return {Token} + */ + createClass(config) { + return new Token(config); + } + + /** + * Create a token model + * @method create + * @param {Object} config + * @param {String} config.userId The ID of the associated user + * @param {String} config.value The token value + * @param {String} config.name The token name + * @param {String} config.description The token description + * @return {Promise} + */ + create(config) { + config.lastUsed = null; + + return super.create(config); + } + + /** + * Get an instance of the TokenFactory + * @method getInstance + * @param {Object} config + * @param {Datastore} config.datastore A datastore instance + * @return {TokenFactory} + */ + static getInstance(config) { + instance = BaseFactory.getInstance(TokenFactory, instance, config); + + return instance; + } +} + +module.exports = TokenFactory; diff --git a/lib/user.js b/lib/user.js index f59763c7..abf78c69 100644 --- a/lib/user.js +++ b/lib/user.js @@ -3,6 +3,8 @@ const BaseModel = require('./base'); const iron = require('iron'); const nodeify = require('./nodeify'); +const PAGINATE_COUNT = 50; +const PAGINATE_PAGE = 1; // Get symbols for private fields const password = Symbol('password'); @@ -43,6 +45,60 @@ class UserModel extends BaseModel { [this.token, this[password], iron.defaults]); } + /** Fetch a user's tokens + /* @property tokens + /* @return {Promise} + */ + get tokens() { + const listConfig = { + params: { + userId: this.id + }, + paginate: { + count: PAGINATE_COUNT, + page: PAGINATE_PAGE + } + }; + + // Lazy load factory dependency to prevent circular dependency issues + // https://nodejs.org/api/modules.html#modules_cycles + /* eslint-disable global-require */ + const TokenFactory = require('./tokenFactory'); + /* eslint-enable global-require */ + const factory = TokenFactory.getInstance(); + const tokens = factory.list(listConfig); + + // ES6 has weird getters and setters in classes, + // so we redefine the pipeline property here to resolve to the + // resulting promise and not try to recreate the factory, etc. + Object.defineProperty(this, 'tokens', { + enumerable: true, + value: tokens + }); + + return tokens; + } + + /** + * Test if a token is valid and belongs to the user + * @method validateToken + * @param {String} uuid UUID of token to validate + * @return {Promise} + */ + validateToken(uuid) { + return this.tokens.then((tokens) => { + const token = tokens.find(t => t.uuid === uuid); + + if (!token) { + throw new Error('Token has been revoked.'); + } + + token.lastUsed = (new Date()).toISOString(); + + return token.update(); + }); + } + /** * Get permissions on a specific repo * @method getPermissions diff --git a/lib/userFactory.js b/lib/userFactory.js index c3878ff9..7b1fc713 100644 --- a/lib/userFactory.js +++ b/lib/userFactory.js @@ -33,7 +33,7 @@ class UserFactory extends BaseFactory { /** * Instantiate a User class * @method createClass - * @param config + * @param {Object} config * @return {User} */ createClass(config) { diff --git a/package.json b/package.json index e20496f2..03173f0b 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,6 @@ "hoek": "^4.0.1", "iron": "^4.0.1", "screwdriver-config-parser": "^3.0.1", - "screwdriver-data-schema": "^16.0.0" + "screwdriver-data-schema": "^16.8.2" } } diff --git a/test/lib/build.test.js b/test/lib/build.test.js index 201bb710..b479ea8c 100644 --- a/test/lib/build.test.js +++ b/test/lib/build.test.js @@ -328,6 +328,10 @@ describe('Build Model', () => { }); }); + afterEach(() => { + sandbox.restore(); + }); + it('promises to start a build', () => build.start() .then(() => { diff --git a/test/lib/token.test.js b/test/lib/token.test.js new file mode 100644 index 00000000..f57dd22d --- /dev/null +++ b/test/lib/token.test.js @@ -0,0 +1,75 @@ +'use strict'; + +const assert = require('chai').assert; +const sinon = require('sinon'); +const mockery = require('mockery'); +const schema = require('screwdriver-data-schema'); +const BaseModel = require('../../lib/base'); +const TokenModel = require('../../lib/token'); + +sinon.assert.expose(assert, { prefix: '' }); +require('sinon-as-promised'); + +describe('Token Model', () => { + const password = 'password'; + let datastore; + let createConfig; + let token; + + before(() => { + mockery.enable({ + useCleanCache: true, + warnOnUnregistered: false + }); + datastore = { + update: sinon.stub() + }; + }); + + beforeEach(() => { + datastore.update.resolves({}); + + createConfig = { + datastore, + userId: 12345, + uuid: '1a2b3c', + id: 6789, + name: 'Mobile client auth token', + description: 'For the mobile app', + lastUsed: '2017-05-10T01:49:59.327Z', + password + }; + token = new TokenModel(createConfig); + }); + + after(() => { + mockery.disable(); + }); + + it('is constructed properly', () => { + assert.instanceOf(token, TokenModel); + assert.instanceOf(token, BaseModel); + schema.models.token.allKeys.forEach((key) => { + assert.strictEqual(token[key], createConfig[key]); + }); + }); + + describe('update', () => { + it('promises to update a token', () => { + const newTimestamp = '2017-05-13T02:01:17.588Z'; + + token.lastUsed = newTimestamp; + + return token.update() + .then(() => { + assert.calledWith(datastore.update, { + table: 'tokens', + params: { + id: 6789, + lastUsed: newTimestamp + } + }); + }); + }); + }); +}); diff --git a/test/lib/tokenFactory.test.js b/test/lib/tokenFactory.test.js new file mode 100644 index 00000000..afa97ecd --- /dev/null +++ b/test/lib/tokenFactory.test.js @@ -0,0 +1,120 @@ +'use strict'; + +const assert = require('chai').assert; +const mockery = require('mockery'); +const sinon = require('sinon'); + +sinon.assert.expose(assert, { prefix: '' }); +require('sinon-as-promised'); + +describe('Token Factory', () => { + const name = 'mobile_token'; + const description = 'a token for a mobile app'; + const uuid = 'abc123'; + const userId = 6789; + const tokenId = 12345; + const tokenData = { + id: tokenId, + userId, + description, + name, + uuid, + lastUsed: null + }; + let TokenFactory; + let datastore; + let factory; + let Token; + + before(() => { + mockery.enable({ + useCleanCache: true, + warnOnUnregistered: false + }); + }); + + beforeEach(() => { + datastore = { + save: sinon.stub() + }; + + /* eslint-disable global-require */ + Token = require('../../lib/token'); + TokenFactory = require('../../lib/tokenFactory'); + /* eslint-enable global-require */ + + factory = new TokenFactory({ datastore }); + }); + + afterEach(() => { + datastore = null; + mockery.deregisterAll(); + mockery.resetCache(); + }); + + after(() => { + mockery.disable(); + }); + + describe('createClass', () => { + it('should return a Token', () => { + const model = factory.createClass(tokenData); + + assert.instanceOf(model, Token); + }); + }); + + describe('create', () => { + it('should create a Token', () => { + const expected = { + userId, + name, + description, + uuid, + lastUsed: null + }; + + datastore.save.resolves(tokenData); + + return factory.create({ + userId, + name, + description, + uuid + }).then((model) => { + assert.isTrue(datastore.save.calledOnce); + assert.calledWith(datastore.save, { + params: expected, + table: 'tokens' + }); + assert.instanceOf(model, Token); + Object.keys(tokenData).forEach((key) => { + assert.strictEqual(model[key], tokenData[key]); + }); + }); + }); + }); + + describe('getInstance', () => { + let config; + + beforeEach(() => { + config = { datastore }; + }); + + it('should get an instance', () => { + const f1 = TokenFactory.getInstance(config); + const f2 = TokenFactory.getInstance(config); + + assert.instanceOf(f1, TokenFactory); + assert.instanceOf(f2, TokenFactory); + + assert.equal(f1, f2); + }); + + it('should throw an error when config not supplied', () => { + assert.throw(TokenFactory.getInstance, + Error, 'No datastore provided to TokenFactory'); + }); + }); +}); diff --git a/test/lib/user.test.js b/test/lib/user.test.js index 1d3effce..b4658bda 100644 --- a/test/lib/user.test.js +++ b/test/lib/user.test.js @@ -6,6 +6,7 @@ const sinon = require('sinon'); const schema = require('screwdriver-data-schema'); sinon.assert.expose(assert, { prefix: '' }); +require('sinon-as-promised'); describe('User Model', () => { const password = 'password'; @@ -15,6 +16,7 @@ describe('User Model', () => { let scmMock; let hashaMock; let ironMock; + let tokenFactoryMock; let user; let BaseModel; let createConfig; @@ -42,15 +44,19 @@ describe('User Model', () => { unseal: sinon.stub(), defaults: {} }; + tokenFactoryMock = { + list: sinon.stub() + }; mockery.registerMock('screwdriver-hashr', hashaMock); mockery.registerMock('iron', ironMock); + mockery.registerMock('./tokenFactory', { + getInstance: sinon.stub().returns(tokenFactoryMock) }); - // eslint-disable-next-line global-require + /* eslint-disable global-require */ UserModel = require('../../lib/user'); - - // eslint-disable-next-line global-require BaseModel = require('../../lib/base'); + /* eslint-enable global-require */ createConfig = { datastore, @@ -64,7 +70,6 @@ describe('User Model', () => { }); afterEach(() => { - datastore = null; mockery.deregisterAll(); mockery.resetCache(); }); @@ -186,4 +191,76 @@ describe('User Model', () => { }); }); }); + + describe('get tokens', () => { + const paginate = { + page: 1, + count: 50 + }; + + it('has a tokens getter', () => { + const listConfig = { + params: { + userId: createConfig.id + }, + paginate + }; + + tokenFactoryMock.list.resolves(null); + // when we fetch tokens it resolves to a promise + assert.isFunction(user.tokens.then); + // and a factory is called to create that promise + assert.calledWith(tokenFactoryMock.list, listConfig); + + // When we call user.tokens again it is still a promise + assert.isFunction(user.tokens.then); + // ...but the factory was not recreated, since the promise is stored + // as the model's tokens property, now + assert.calledOnce(tokenFactoryMock.list); + }); + }); + + describe('validateToken', () => { + let sandbox; + let mockToken; + + beforeEach(() => { + sandbox = sinon.sandbox.create(); + sandbox.useFakeTimers(0); + + mockToken = { + id: 123, + uuid: '110ec58a-a0f2-4ac4-8393-c866d813b8d1', + userId: 'd398fb192747c9a0124e9e5b4e6e8e841cf8c71c', + name: 'token1', + description: 'token number 1', + lastUsed: null, + update: sinon.stub() + }; + + tokenFactoryMock.list.resolves([mockToken]); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('validates a valid token and updates its lastUsed property', () => + user.validateToken('110ec58a-a0f2-4ac4-8393-c866d813b8d1') + .then(() => { + assert.calledOnce(mockToken.update); + assert.equal(mockToken.lastUsed, '1970-01-01T00:00:00.000Z'); + })); + + it('rejects an invalid token', () => { + user.validateToken('a different token') + .then(() => { + assert.fail('Should not get here.'); + }) + .catch((err) => { + assert.equal(err.message, 'Token has been revoked.'); + assert.notCalled(mockToken.update); + }); + }); + }); });