diff --git a/api/v1/controllers/admin.js b/api/v1/controllers/admin.js index d91b9d06ce..7f57bd9fc9 100644 --- a/api/v1/controllers/admin.js +++ b/api/v1/controllers/admin.js @@ -16,7 +16,6 @@ const featureToggles = require('feature-toggles'); const sampleStore = require('../../../cache/sampleStore'); const sampleStoreInit = require('../../../cache/sampleStoreInit'); -const sampleStorePersist = require('../../../cache/sampleStorePersist'); const apiErrors = require('../apiErrors'); const httpStatus = require('../constants').httpStatus; const authUtils = require('../helpers/authUtils'); diff --git a/api/v1/controllers/aspects.js b/api/v1/controllers/aspects.js index 89bffce426..016e1c2302 100644 --- a/api/v1/controllers/aspects.js +++ b/api/v1/controllers/aspects.js @@ -30,8 +30,7 @@ const doPost = require('../helpers/verbs/doPost'); const doPut = require('../helpers/verbs/doPut'); const u = require('../helpers/verbs/utils'); const httpStatus = require('../constants').httpStatus; -const ZERO = 0; -const ONE = 1; +const redisOps = require('../../../cache/redisOps'); /** * Validates the given fields from request body or url. @@ -175,10 +174,37 @@ module.exports = { const toPost = params.queryBody.value; const options = {}; options.where = u.whereClauseForNameInArr(toPost); + let users; userProps.model.findAll(options) .then((usrs) => { + users = usrs; + if (featureToggles.isFeatureEnabled('enableRedisSampleStore')) { + return u.findByKey(helper, params) + .then((o) => u.isWritable(req, o, + featureToggles.isFeatureEnabled('enforceWritePermission'))) + .then((o) => redisOps.getValue('aspect', o.name)) + .then((cachedAspect) => { + if (cachedAspect) { + const userSet = new Set(); + usrs.forEach((user) => { + userSet.add(user.dataValues.name); + }); + cachedAspect.writers = cachedAspect.writers || []; + Array.from(userSet).forEach((user) => { + cachedAspect.writers.push(user); + }); + return redisOps.hmSet('aspect', cachedAspect.name, cachedAspect); + } + + return Promise.resolve(true); + }) + .catch((err) => Promise.resolve(err)); + } + return Promise.resolve(true); + }) + .then(() => { doPostAssoc(req, res, next, helper, - helper.belongsToManyAssoc.users, usrs); + helper.belongsToManyAssoc.users, users); }); }, // postAspectWriters @@ -237,7 +263,25 @@ module.exports = { * @param {Function} next - The next middleware function in the stack */ deleteAspectWriters(req, res, next) { - doDeleteAllAssoc(req, res, next, helper, helper.belongsToManyAssoc.users); + if (featureToggles.isFeatureEnabled('enableRedisSampleStore')) { + const params = req.swagger.params; + u.findByKey(helper, params) + .then((o) => u.isWritable(req, o, + featureToggles.isFeatureEnabled('enforceWritePermission'))) + .then((o) => redisOps.getValue('aspect', o.name)) + .then((cachedAspect) => { + if (cachedAspect) { + cachedAspect.writers = []; + return redisOps.hmSet('aspect', cachedAspect.name, cachedAspect); + } + return Promise.resolve(true); + }) + .then(() => doDeleteAllAssoc(req, res, next, helper, + helper.belongsToManyAssoc.users)) + .catch((err) => u.handleError(next, err, helper.modelName)); + } else { + doDeleteAllAssoc(req, res, next, helper, helper.belongsToManyAssoc.users); + } }, /** @@ -251,8 +295,44 @@ module.exports = { */ deleteAspectWriter(req, res, next) { const userNameOrId = req.swagger.params.userNameOrId.value; - doDeleteOneAssoc(req, res, next, helper, + let aspectName; + let userName; + if (featureToggles.isFeatureEnabled('enableRedisSampleStore')) { + const params = req.swagger.params; + u.findByKey(helper, params) + .then((o) => u.isWritable(req, o, + featureToggles.isFeatureEnabled('enforceWritePermission'))) + .then((o) => { + aspectName = o.name; + console.log('aspectName', aspectName); + const options = {}; + options.where = u.whereClauseForNameOrId(params.userNameOrId.value); + return u.findAssociatedInstances(helper, + params, helper.belongsToManyAssoc.users, options); + }) + .then((o) => { + console.log('userREturned', o); + u.throwErrorForEmptyArray(o, + params.userNameOrId.value, userProps.modelName); + userName = o[0].dataValues.name; + return redisOps.getValue('aspect', aspectName); + }) + .then((cachedAspect) => { + if (cachedAspect) { + console.log('userName to delete', userName); + cachedAspect.writers = cachedAspect.writers + .filter((writer) => writer !== userName); + return redisOps.hmSet('aspect', cachedAspect.name, cachedAspect); + } + return Promise.resolve(true); + }) + .then(() => doDeleteOneAssoc(req, res, next, helper, + helper.belongsToManyAssoc.users, userNameOrId)) + .catch((err) => u.handleError(next, err, helper.modelName)); + } else { + doDeleteOneAssoc(req, res, next, helper, helper.belongsToManyAssoc.users, userNameOrId); + } }, /** diff --git a/api/v1/controllers/samples.js b/api/v1/controllers/samples.js index 8ea782d0f1..42900a7d63 100644 --- a/api/v1/controllers/samples.js +++ b/api/v1/controllers/samples.js @@ -12,7 +12,6 @@ 'use strict'; // eslint-disable-line strict const featureToggles = require('feature-toggles'); -const apiErrors = require('../apiErrors'); const helper = require('../helpers/nouns/samples'); const subHelper = require('../helpers/nouns/subjects'); const doDelete = require('../helpers/verbs/doDelete'); diff --git a/cache/redisOps.js b/cache/redisOps.js index ce566685fc..b351dc4cae 100644 --- a/cache/redisOps.js +++ b/cache/redisOps.js @@ -13,6 +13,7 @@ const redisStore = require('./sampleStore'); const redisClient = require('./redisCache').client.sampleStore; +const rsConstant = redisStore.constants; const subjectType = redisStore.constants.objectType.subject; const aspectType = redisStore.constants.objectType.aspect; const sampleType = redisStore.constants.objectType.sample; @@ -45,6 +46,23 @@ function hmSet(objectName, name, value) { .catch((err) => Promise.reject(err)); } // hmSet +/** + * Get the value that is mapped to a key + * @param {String} type - The type of the object on which the operation is to + * be performed + * @param {String} name - Name of the key + * @returns {Promise} - which resolves to the value associated with the key + */ +function getValue(type, name) { + const nameKey = redisStore.toKey(type, name); + return redisClient.hgetallAsync(nameKey) + .then((value) => { + redisStore.arrayStringsToJson(value, rsConstant.fieldsToStringify[type]); + return Promise.resolve(value); + }) + .catch((err) => Promise.reject(err)); +} // getValue + /** * Adds an entry identified by name to the master list of indices identified * by "type" @@ -246,7 +264,7 @@ function renameKeys(type, objectName, oldName, newName) { * * @param {Array} arr Contains strings * @param {String} str The string to check against strings in array - * @retuns {Boolean} whether the string is unique or not + * @returns {Boolean} whether the string is unique or not */ function isStringInArray(arr, str) { let isStringUnique = true; @@ -325,7 +343,6 @@ module.exports = { * @returns {Promise} - to update the subject */ addAspectNameToSubject(subjKey, subjectId, name) { - let isNameUnique = true; return redisClient.hgetallAsync(subjKey) .then((subject) => { const aspectNames = JSON.parse(subject.aspectNames || '[]'); @@ -352,7 +369,7 @@ module.exports = { .then((subject) => { // if case insensitive match, return true - let aspects = JSON.parse(subject.aspectNames); + const aspects = JSON.parse(subject.aspectNames); return !isStringInArray(aspects, aspName); }); }, @@ -464,4 +481,6 @@ module.exports = { aspectType, sampleType, + + getValue, }; // export diff --git a/cache/sampleStoreInit.js b/cache/sampleStoreInit.js index ef2758ae57..ea3e54fa74 100644 --- a/cache/sampleStoreInit.js +++ b/cache/sampleStoreInit.js @@ -93,7 +93,7 @@ function populateAspects() { const aspectIdx = []; const cmds = []; - // for each aspect, add its writers to its writers field. + // for each aspect, add the associated writers to its "writers" field. for (let i = 0; i < aspects.length; i++) { const a = aspects[i]; a.dataValues.writers = []; diff --git a/tests/cache/models/aspects/deleteWriters.js b/tests/cache/models/aspects/deleteWriters.js new file mode 100644 index 0000000000..9380c44be7 --- /dev/null +++ b/tests/cache/models/aspects/deleteWriters.js @@ -0,0 +1,290 @@ +/** + * 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/cache/models/aspects/deleteWriters.js + */ +'use strict'; // eslint-disable-line strict + +const supertest = require('supertest'); +const api = supertest(require('../../../../index').app); +const constants = require('../../../../api/v1/constants'); +const tu = require('../../../testUtils'); +const rtu = require('../redisTestUtil'); +const redisClient = rtu.redisClient; +const samstoinit = rtu.samstoinit; +const sampleStore = rtu.sampleStore; +const expect = require('chai').expect; +const Aspect = tu.db.Aspect; +const User = tu.db.User; +const writersPath = '/v1/aspects/{key}/writers'; +const writerPath = '/v1/aspects/{key}/writers/{userNameOrId}'; +const aspectPath = '/v1/aspects/{key}'; + +describe('api: aspects: permissions', () => { + let token; + let otherValidToken; + let aspect; + let user; + const aspectToCreate = { + name: `${tu.namePrefix}ASPECTNAME`, + timeout: '110s', + isPublished: true, + }; + + beforeEach((done) => { + tu.toggleOverride('enforceWritePermission', true); + tu.toggleOverride('enableRedisSampleStore', true); + tu.createToken() + .then((returnedToken) => { + token = returnedToken; + done(); + }) + .catch((err) => done(err)); + }); + + beforeEach((done) => { + Aspect.create(aspectToCreate) + .then((asp) => { + aspect = asp; + }).then(() => + + /** + * tu.createToken creates an user and an admin user is already created, + * so one use of these. + */ + User.findOne({ where: { name: tu.userName } })) + .then((usr) => aspect.addWriter(usr)) + .then(() => tu.createSecondUser()) + .then((secUsr) => { + aspect.addWriter(secUsr); + user = secUsr; + }) + .then(() => tu.createThirdUser()) + .then((tUsr) => tu.createTokenFromUserName(tUsr.name)) + .then((tkn) => { + otherValidToken = tkn; + return samstoinit.populate(); + }) + .then(() => done()) + .catch((err) => done(err)); + }); + + afterEach(rtu.forceDelete); + afterEach(tu.forceDeleteUser); + + after(() => tu.toggleOverride('enableRedisSampleStore', false)); + after(() => tu.toggleOverride('enforceWritePermission', false)); + + describe('delete resource without permission', () => { + it('return 403 when deleting aspect without permission', (done) => { + api.delete(aspectPath.replace('{key}', aspect.id)) + .set('Authorization', otherValidToken) + .expect(constants.httpStatus.FORBIDDEN) + .end((err /* , res */) => { + if (err) { + done(err); + } + + done(); + }); + }); + }); + + describe('delete writer(s)', () => { + it('remove write permission associated with the resource', (done) => { + api.delete(writersPath.replace('{key}', aspect.id)) + .set('Authorization', token) + .expect(constants.httpStatus.NO_CONTENT) + .end((err /* , res */) => { + if (err) { + return done(err); + } + + api.get(writersPath.replace('{key}', aspect.id)) + .set('Authorization', token) + .expect(constants.httpStatus.OK) + .expect((res) => { + expect(res.body).to.have.length(0); + + // make sure the writers are added to the aspect in redis too + redisClient.hgetallAsync('samsto:aspect:___aspectname') + .then((asp) => { + sampleStore.arrayStringsToJson(asp, + sampleStore.constants.fieldsToStringify.aspect); + expect(asp.writers.length).to.equal(0); + }); + }) + .end((_err /* , res */) => { + if (_err) { + return done(_err); + } + + return done(); + }); + return null; + }); + }); + + it('return 403 when a token is not passed to the header', (done) => { + api.delete(writersPath.replace('{key}', aspect.id)) + .expect(constants.httpStatus.FORBIDDEN) + .end((err /* , res */) => { + if (err) { + done(err); + } + + done(); + }); + }); + + it('return 404 deleting writers for an aspect not in ' + + ' the system', (done) => { + api.delete(writersPath.replace('{key}', 'InvalidAspect')) + .expect(constants.httpStatus.NOT_FOUND) + .end((err /* , res */) => { + if (err) { + done(err); + } + + done(); + }); + }); + + it('return 403 when deleteting writers using a token generated for ' + + 'a user not already in the list of writers', (done) => { + api.delete(writersPath.replace('{key}', aspect.id)) + .set('Authorization', otherValidToken) + .expect(constants.httpStatus.FORBIDDEN) + .end((err /* , res */) => { + if (err) { + done(err); + } + + done(); + }); + }); + + it('remove write permission using username', (done) => { + api.delete(writerPath.replace('{key}', aspect.id) + .replace('{userNameOrId}', user.name)) + .set('Authorization', token) + .expect(constants.httpStatus.NO_CONTENT) + .end((err /* , res */) => { + if (err) { + return done(err); + } + + api.get(writersPath.replace('{key}', aspect.id)) + .set('Authorization', token) + .expect(constants.httpStatus.OK) + .expect((res) => { + expect(res.body).to.have.length(1); + }) + .end((_err /* , res */) => { + if (_err) { + return done(_err); + } + + return done(); + }); + return null; + }); + }); + + it('remove write permission using user id', (done) => { + api.delete(writerPath.replace('{key}', aspect.id) + .replace('{userNameOrId}', user.id)) + .set('Authorization', token) + .expect(constants.httpStatus.NO_CONTENT) + .end((err /* , res */) => { + if (err) { + return done(err); + } + + api.get(writersPath.replace('{key}', aspect.id)) + .set('Authorization', token) + .expect(constants.httpStatus.OK) + .expect((res) => { + expect(res.body).to.have.length(1); + + // make sure the + redisClient.hgetallAsync('samsto:aspect:___aspectname') + .then((asp) => { + sampleStore.arrayStringsToJson(asp, + sampleStore.constants.fieldsToStringify.aspect); + expect(asp.writers.length).to.equal(1); + }); + }) + .end((_err /* , res */) => { + if (_err) { + return done(_err); + } + + return done(); + }); + return null; + }); + }); + + it('Write permissions should not be effected for invalid user', (done) => { + api.delete(writerPath.replace('{key}', aspect.id) + .replace('{userNameOrId}', 'invalidUserName')) + .set('Authorization', token) + .expect(constants.httpStatus.NOT_FOUND) + .end((err /* , res */) => { + if (err) { + return done(err); + } + + api.get(writersPath.replace('{key}', aspect.id)) + .set('Authorization', token) + .expect(constants.httpStatus.OK) + .expect((res) => { + expect(res.body).to.have.length(2); + }) + .end((_err /* , res */) => { + if (_err) { + return done(_err); + } + + return done(); + }); + return null; + }); + }); + + it('return 403 when deleteting a writer using a token ' + + 'generated for a user not already in the list of writers', (done) => { + api.delete(writerPath.replace('{key}', aspect.id) + .replace('{userNameOrId}', 'invalidUserName')) + .set('Authorization', otherValidToken) + .expect(constants.httpStatus.FORBIDDEN) + .end((err /* , res */) => { + if (err) { + done(err); + } + + done(); + }); + }); + + it('return 404 when trying to delete an invalidResource', (done) => { + api.delete(writerPath.replace('{key}', 'invalidResource')) + .set('Authorization', otherValidToken) + .expect(constants.httpStatus.NOT_FOUND) + .end((err /* , res */) => { + if (err) { + done(err); + } + + done(); + }); + }); + }); +}); diff --git a/tests/cache/models/aspects/postWriters.js b/tests/cache/models/aspects/postWriters.js new file mode 100644 index 0000000000..e12e81af8d --- /dev/null +++ b/tests/cache/models/aspects/postWriters.js @@ -0,0 +1,170 @@ +/** + * 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/cache/models/aspects/postWriters.js + */ +'use strict'; // eslint-disable-line strict + +const supertest = require('supertest'); +const api = supertest(require('../../../../index').app); +const constants = require('../../../../api/v1/constants'); +const tu = require('../../../testUtils'); +const rtu = require('../redisTestUtil'); +const redisClient = rtu.redisClient; +const samstoinit = rtu.samstoinit; +const sampleStore = rtu.sampleStore; +const expect = require('chai').expect; +const Aspect = tu.db.Aspect; +const User = tu.db.User; +const postWritersPath = '/v1/aspects/{key}/writers'; + +describe('api: aspects: post writers', () => { + let token; + let aspect; + let firstUser; + let secondUser; + let otherValidToken; + const userNameArray = []; + const aspectToCreate = { + name: `${tu.namePrefix}ASPECTNAME`, + timeout: '110s', + isPublished: true, + tags: ['tag1'], + }; + + before((done) => { + tu.toggleOverride('enforceWritePermission', true); + tu.toggleOverride('enableRedisSampleStore', true); + tu.createToken() + .then((returnedToken) => { + token = returnedToken; + done(); + }) + .catch(done); + }); + + + before((done) => { + Aspect.create(aspectToCreate) + .then((asp) => { + aspect = asp; + }).then(() => + + /** + * tu.createToken creates an user and an admin user is already created, + * so one use of these. + */ + User.findOne({ where: { name: tu.userName } })) + .then((usr) => { + firstUser = usr; + userNameArray.push(firstUser.name); + return tu.createSecondUser(); + }) + .then((secUsr) => { + secondUser = secUsr; + userNameArray.push(secondUser.name); + return tu.createThirdUser(); + }) + .then((tUsr) => { + return tu.createTokenFromUserName(tUsr.name); + }) + .then((tkn) => { + otherValidToken = tkn; + return samstoinit.populate(); + }) + .then(() => done()) + .catch(done); + }); + + after(rtu.forceDelete); + after(() => tu.toggleOverride('enableRedisSampleStore', false)); + after(() => tu.toggleOverride('enforceWritePermission', false)); + after(tu.forceDeleteUser); + + it('add writers to the record and make sure the writers are ' + + 'associated with the right object', (done) => { + api.post(postWritersPath.replace('{key}', aspect.id)) + .set('Authorization', token) + .send(userNameArray) + .expect(constants.httpStatus.CREATED) + .expect((res) => { + expect(res.body).to.have.length(2); + + const userOne = res.body[0]; + const userTwo = res.body[1]; + + expect(userOne.aspectId).to.not.equal(undefined); + expect(userOne.userId).to.not.equal(undefined); + + expect(userTwo.aspectId).to.not.equal(undefined); + expect(userTwo.userId).to.not.equal(undefined); + + // make sure the writers are added to the aspect in redis too + redisClient.hgetallAsync('samsto:aspect:___aspectname') + .then((asp) => { + sampleStore.arrayStringsToJson(asp, + sampleStore.constants.fieldsToStringify.aspect); + expect(asp.writers.length).to.equal(2); + expect(asp.writers).to.have + .members([firstUser.name, secondUser.name]); + }); + }) + .end((err /* , res */) => { + if (err) { + done(err); + } + + done(); + }); + }); + + it('return 403 for adding writers using an user that is not '+ + 'already a writer of that resource', (done) => { + api.post(postWritersPath.replace('{key}', aspect.id)) + .set('Authorization', otherValidToken) + .send(userNameArray) + .expect(constants.httpStatus.FORBIDDEN) + .end((err /* , res */) => { + if (err) { + done(err); + } + + done(); + }); + }); + + it('return 404 for adding writers to an invalid aspect', (done) => { + api.post(postWritersPath.replace('{key}', 'invalidAspect')) + .set('Authorization', otherValidToken) + .send(userNameArray) + .expect(constants.httpStatus.NOT_FOUND) + .end((err /* , res */) => { + if (err) { + done(err); + } + + done(); + }); + }); + + it('a request body that is not an array should not be accepted', (done) => { + const firstUserName = firstUser.name; + api.post(postWritersPath.replace('{key}', aspect.id)) + .set('Authorization', token) + .send({ firstUserName }) + .expect(constants.httpStatus.BAD_REQUEST) + .end((err /* , res */) => { + if (err) { + done(err); + } + + done(); + }); + }); +});