From 15f7113f6ab65354b53b8df1a4d08f034dff1b91 Mon Sep 17 00:00:00 2001 From: Ian Goldstein Date: Thu, 2 Mar 2017 16:12:44 -0800 Subject: [PATCH] Initial commit. Still need to work on the requirement that there be NO race conditions where one process is trying to populate Redis from db while another process is trying to persist samples from Redis to db. --- api/v1/apiErrors.js | 12 +- api/v1/controllers/admin.js | 62 +++++ api/v1/swagger.yaml | 25 ++ tests/api/v1/admin/rebuildSampleStore.js | 280 +++++++++++++++++++++++ tests/api/v1/admin/utils.js | 24 ++ 5 files changed, 402 insertions(+), 1 deletion(-) create mode 100644 api/v1/controllers/admin.js create mode 100644 tests/api/v1/admin/rebuildSampleStore.js create mode 100644 tests/api/v1/admin/utils.js diff --git a/api/v1/apiErrors.js b/api/v1/apiErrors.js index 6388cb11f8..7b3c64a7f6 100644 --- a/api/v1/apiErrors.js +++ b/api/v1/apiErrors.js @@ -72,7 +72,7 @@ apiErrors.create({ }); apiErrors.create({ - code: 11101, + code: 11104, status: 400, name: 'InvalidFilterParameterError', parent: apiErrors.ValidationError, @@ -81,6 +81,16 @@ apiErrors.create({ 'an include filter or an exclude filter, but not the combination of both.', }); +apiErrors.create({ + code: 11105, + status: 400, + name: 'InvalidSampleStoreState', + parent: apiErrors.ValidationError, + fields: [], + defaultMessage: 'You cannot rebuild the sample store if the ' + + 'ENABLE_REDIS_SAMPLE_STORE feature is not enabled.', +}); + // ---------------------------------------------------------------------------- // Not Found // ---------------------------------------------------------------------------- diff --git a/api/v1/controllers/admin.js b/api/v1/controllers/admin.js new file mode 100644 index 0000000000..3c10ec9eb8 --- /dev/null +++ b/api/v1/controllers/admin.js @@ -0,0 +1,62 @@ + + +/** + * Copyright (c) 2017, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the repo root or + * https://opensource.org/licenses/BSD-3-Clause + */ + +/** + * api/v1/controllers/admin.js + */ +'use strict'; // eslint-disable-line strict + +const featureToggles = require('feature-toggles'); +const sampleStore = require('../../../cache/sampleStore'); +const sampleStoreInit = require('../../../cache/sampleStoreInit'); +const apiErrors = require('../apiErrors'); +const httpStatus = require('../constants').httpStatus; +const authUtils = require('../helpers/authUtils'); +const u = require('../helpers/verbs/utils'); + +module.exports = { + + /** + * POST /admin/sampleStore/rebuild + * + * Rebuild the redis sampleStore from the samples in the database. Admin + * only. + * + * @param {IncomingMessage} req - The request object + * @param {ServerResponse} res - The response object + * @param {Function} next - The next middleware function in the stack + */ + rebuildSampleStore(req, res, next) { + authUtils.isAdmin(req) + .then((ok) => { + if (ok) { + const enabled = featureToggles + .isFeatureEnabled(sampleStore.constants.featureName); + if (enabled) { + sampleStoreInit.eradicate() + .then(() => sampleStoreInit.populate()) + .then(() => { + res.status(httpStatus.NO_CONTENT).json(); + }); + } else { + const err = new apiErrors.InvalidSampleStoreState({ + explanation: 'You cannot rebuild the sample store if the ' + + 'ENABLE_REDIS_SAMPLE_STORE feature is not enabled.', + }); + next(err); + } + } else { + u.forbidden(next); + } + }) + .catch(() => u.forbidden(next)); + }, + +}; // exports diff --git a/api/v1/swagger.yaml b/api/v1/swagger.yaml index d039d5f22a..767e4932eb 100644 --- a/api/v1/swagger.yaml +++ b/api/v1/swagger.yaml @@ -61,6 +61,31 @@ produces: # ============================================================================= paths: + # --------------------------------------------------------------------------- + /admin/sampleStore/rebuild: + x-swagger-router-controller: admin + post: + security: + - jwt: [] + summary: >- + Rebuild the redis sampleStore from the samples in the database (admin only) + tags: [ admin ] + description: >- + Rebuild the redis sampleStore from the samples in the database. Requires user to have an admin profile. If the Refocus configuration parameter `useAccessToken` is set to `true`, you must include an `Authorization` request header with your [JSON Web Token](https://tools.ietf.org/html/rfc7519) (JWT) as the value. You can get a token using `POST /v1/register` or `POST /v1/tokens`. + operationId: rebuildSampleStore + responses: + 204: + description: >- + Success. + 400: + $ref: "#/responses/400" + 401: + $ref: "#/responses/401" + 404: + $ref: "#/responses/404" + default: + $ref: "#/responses/genericError" + # --------------------------------------------------------------------------- /aspects: x-swagger-router-controller: aspects diff --git a/tests/api/v1/admin/rebuildSampleStore.js b/tests/api/v1/admin/rebuildSampleStore.js new file mode 100644 index 0000000000..a9d5a1b5ae --- /dev/null +++ b/tests/api/v1/admin/rebuildSampleStore.js @@ -0,0 +1,280 @@ +/** + * Copyright (c) 2017, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the repo root or + * https://opensource.org/licenses/BSD-3-Clause + */ + +/** + * tests/api/v1/admin/rebuildSampleStore.js + */ +'use strict'; + +const supertest = require('supertest'); +const featureToggles = require('feature-toggles'); +const sampleStore = require('../../../../cache/sampleStore'); +const redisClient = require('../../../../cache/redisCache').client.sampleStore; +const api = supertest(require('../../../../index').app); +const constants = require('../../../../api/v1/constants'); +const u = require('./utils'); +const tu = require('../../../testUtils'); +const Aspect = tu.db.Aspect; +const Subject = tu.db.Subject; +const Sample = tu.db.Sample; +const jwtUtil = require('../../../../utils/jwtUtil'); +const path = '/v1/admin/sampleStore/rebuild'; +const adminUser = require('../../../../config').db.adminUser; +const expect = require('chai').expect; +const initialFeatureState = featureToggles + .isFeatureEnabled(sampleStore.constants.featureName); + +describe(`api: POST ${path} (feature is off):`, () => { + let token; + const predefinedAdminUserToken = jwtUtil.createToken( + adminUser.name, adminUser.name + ); + const uname = `${tu.namePrefix}test@test.com`; + let testUserToken = ''; + + /** + * Register a non-admin user and an admin user; grab the predefined admin + * user's token + */ + before((done) => { + tu.toggleOverride(sampleStore.constants.featureName, false); + tu.createToken() + .then((returnedToken) => { + token = returnedToken; + api.post('/v1/register') + .set('Authorization', token) + .send({ + username: uname, + email: uname, + password: 'abcdefghijklmnopqrstuvwxyz', + }) + .end((err, res) => { + if (err) { + done(err); + } + + testUserToken = res.body.token; + done(); + }); + }) + .catch(done); + }); + + after(u.forceDelete); + after(tu.forceDeleteUser); + after(() => tu.toggleOverride(sampleStore.constants.featureName, + initialFeatureState)); + + it('user is admin', (done) => { + api.post(path) + .set('Authorization', predefinedAdminUserToken) + .send({}) + .expect(constants.httpStatus.BAD_REQUEST) + .end((err /* , res */) => { + if (err) { + return done(err); + } + + return done(); + }); + }); + + it('user is NOT admin', (done) => { + api.post(path) + .set('Authorization', testUserToken) + .send({}) + .expect(constants.httpStatus.FORBIDDEN) + .end((err /* , res */) => { + if (err) { + return done(err); + } + + return done(); + }); + }); +}); + +describe(`api: POST ${path} (feature is on):`, () => { + let token; + const predefinedAdminUserToken = jwtUtil.createToken( + adminUser.name, adminUser.name + ); + const uname = `${tu.namePrefix}test@test.com`; + let testUserToken = ''; + let a1; + let a2; + let a3; + let s1; + let s2; + let s3; + + /** + * Register a non-admin user and an admin user; grab the predefined admin + * user's token + */ + before((done) => { + tu.toggleOverride(sampleStore.constants.featureName, true); + tu.createToken() + .then((returnedToken) => { + token = returnedToken; + api.post('/v1/register') + .set('Authorization', token) + .send({ + username: uname, + email: uname, + password: 'abcdefghijklmnopqrstuvwxyz', + }) + .end((err, res) => { + if (err) { + done(err); + } + + testUserToken = res.body.token; + Aspect.create({ + isPublished: true, + name: `${tu.namePrefix}Aspect1`, + timeout: '30s', + valueType: 'NUMERIC', + criticalRange: [0, 1], + relatedLinks: [ + { name: 'Google', value: 'http://www.google.com' }, + { name: 'Yahoo', value: 'http://www.yahoo.com' }, + ], + }) + .then((created) => (a1 = created)) + .then(() => Aspect.create({ + isPublished: true, + name: `${tu.namePrefix}Aspect2`, + timeout: '10m', + valueType: 'BOOLEAN', + okRange: [10, 100], + })) + .then((created) => (a2 = created)) + .then(() => Aspect.create({ + isPublished: true, + name: `${tu.namePrefix}Aspect3`, + timeout: '10m', + valueType: 'BOOLEAN', + okRange: [10, 100], + })) + .then((created) => (a3 = created)) + .then(() => Subject.create({ + isPublished: true, + name: `${tu.namePrefix}Subject1`, + })) + .then((created) => (s1 = created)) + .then(() => Subject.create({ + isPublished: true, + name: `${tu.namePrefix}Subject2`, + parentId: s1.id, + })) + .then((created) => (s2 = created)) + .then(() => Subject.create({ + isPublished: true, + name: `${tu.namePrefix}Subject3`, + parentId: s1.id, + })) + .then((created) => (s3 = created)) + .then(() => Sample.create({ + subjectId: s2.id, + aspectId: a1.id, + value: '0', + relatedLinks: [ + { name: 'Salesforce', value: 'http://www.salesforce.com' }, + ] + })) + .then(() => Sample.create({ + subjectId: s2.id, + aspectId: a2.id, + value: '50', + relatedLinks: [ + { name: 'Salesforce', value: 'http://www.salesforce.com' }, + ] + })) + .then(() => Sample.create({ + subjectId: s3.id, + aspectId: a1.id, + value: '5', + relatedLinks: [ + { name: 'Salesforce', value: 'http://www.salesforce.com' }, + ] + })) + .then(() => done()) + .catch(done); + }); + }); + }); + + after((done) => { + u.forceDelete(done) + .then(() => tu.forceDeleteUser) + .then(() => redisClient.flushallAsync()) + .then(() => tu.toggleOverride(sampleStore.constants.featureName, + initialFeatureState)) + .then(() => done()) + .catch(done); + }); + + it('user is admin', (done) => { + api.post(path) + .set('Authorization', predefinedAdminUserToken) + .send({}) + .expect(constants.httpStatus.NO_CONTENT) + .end((err /* , res */) => { + if (err) { + return done(err); + } + + redisClient.smembersAsync(sampleStore.constants.indexKey.aspect) + .then((res) => { + expect(res.includes('samsto:aspect:___aspect1')).to.be.true; + expect(res.includes('samsto:aspect:___aspect2')).to.be.true; + + // Make sure aspects that don't have samples are *also* here + expect(res.includes('samsto:aspect:___aspect3')).to.be.true; + }) + .then(() => redisClient + .smembersAsync(sampleStore.constants.indexKey.sample)) + .then((res) => { + expect(res + .includes('samsto:sample:___subject1.___subject2|___aspect1')) + .to.be.true; + expect(res + .includes('samsto:sample:___subject1.___subject2|___aspect2')) + .to.be.true; + expect(res + .includes('samsto:sample:___subject1.___subject3|___aspect1')) + .to.be.true; + }) + .then(() => redisClient + .smembersAsync(sampleStore.constants.indexKey.subject)) + .then((res) => { + expect(res.includes('samsto:subject:___subject1.___subject2')) + .to.be.true; + expect(res.includes('samsto:subject:___subject1.___subject3')) + .to.be.true; + }) + .then(() => done()) + .catch(done); + }); + }); + + it('user is NOT admin', (done) => { + api.post(path) + .set('Authorization', testUserToken) + .send({}) + .expect(constants.httpStatus.FORBIDDEN) + .end((err /* , res */) => { + if (err) { + return done(err); + } + + return done(); + }); + }); +}); diff --git a/tests/api/v1/admin/utils.js b/tests/api/v1/admin/utils.js new file mode 100644 index 0000000000..190b529f33 --- /dev/null +++ b/tests/api/v1/admin/utils.js @@ -0,0 +1,24 @@ +/** + * Copyright (c) 2017, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the repo root or + * https://opensource.org/licenses/BSD-3-Clause + */ + +/** + * tests/api/v1/admin/utils.js + */ +'use strict'; + +const tu = require('../../../testUtils'); + +const testStartTime = new Date(); + +module.exports = { + forceDelete: () => tu.forceDelete(tu.db.Sample, testStartTime) + .then(() => tu.forceDelete(tu.db.Aspect, testStartTime)) + .then(() => tu.forceDelete(tu.db.Subject, testStartTime)) + .then(() => tu.forceDelete(tu.db.Profile, testStartTime)) + .then(() => tu.forceDelete(tu.db.User, testStartTime)), +};