diff --git a/local/postgres-db/Dockerfile b/local/postgres-db/Dockerfile
index 844b3f09..acc88fc7 100644
--- a/local/postgres-db/Dockerfile
+++ b/local/postgres-db/Dockerfile
@@ -1,2 +1,2 @@
-FROM postgres:9.5
-COPY create-multiple-postgresql-databases.sh /docker-entrypoint-initdb.d/
\ No newline at end of file
+FROM postgres:12.3
+COPY create-multiple-postgresql-databases.sh /docker-entrypoint-initdb.d/
diff --git a/src/constants.js b/src/constants.js
index 97b0371f..6fe0011b 100644
--- a/src/constants.js
+++ b/src/constants.js
@@ -274,7 +274,8 @@ export const M2M_SCOPES = {
ALL: 'all:projects',
READ: 'read:projects',
WRITE: 'write:projects',
- WRITE_BILLING_ACCOUNTS: 'write:projects-billing-accounts',
+ READ_USER_BILLING_ACCOUNTS: 'read:user-billing-accounts',
+ WRITE_PROJECTS_BILLING_ACCOUNTS: 'write:projects-billing-accounts',
},
PROJECT_MEMBERS: {
ALL: 'all:project-members',
diff --git a/src/events/busApi.js b/src/events/busApi.js
index 7b01e055..4e51c98f 100644
--- a/src/events/busApi.js
+++ b/src/events/busApi.js
@@ -15,6 +15,7 @@ import {
import { createEvent } from '../services/busApi';
import models from '../models';
import util from '../util';
+import createTaasJobsFromProject from '../events/projects/postTaasJobs';
/**
* Map of project status and event name sent to bus api
@@ -57,6 +58,14 @@ module.exports = (app, logger) => {
app.on(EVENT.ROUTING_KEY.PROJECT_DRAFT_CREATED, ({ req, project }) => {
logger.debug('receive PROJECT_DRAFT_CREATED event');
+ // create taas jobs from project of type `talent-as-a-service`
+ if (project.type === 'talent-as-a-service') {
+ createTaasJobsFromProject(req, project, logger)
+ .catch((error) => {
+ logger.error(`Error while creating TaaS jobs: ${error}`);
+ });
+ }
+
// send event to bus api
createEvent(BUS_API_EVENT.PROJECT_CREATED, _.assign(project, {
refCode: _.get(project, 'details.utm.code'),
diff --git a/src/events/projects/index.js b/src/events/projects/index.js
index a3db47c5..b3401098 100644
--- a/src/events/projects/index.js
+++ b/src/events/projects/index.js
@@ -5,7 +5,6 @@ import _ from 'lodash';
import Joi from 'joi';
import Promise from 'bluebird';
import config from 'config';
-import axios from 'axios';
import util from '../../util';
import models from '../../models';
import { createPhaseTopic } from '../projectPhases';
@@ -15,27 +14,6 @@ const ES_PROJECT_INDEX = config.get('elasticsearchConfig.indexName');
const ES_PROJECT_TYPE = config.get('elasticsearchConfig.docType');
const eClient = util.getElasticSearchClient();
-/**
- * creates taas job
- * @param {Object} data the job data
- * @return {Object} the job created
- */
-const createTaasJob = async (data) => {
- const token = await util.getM2MToken();
- const headers = {
- 'Content-Type': 'application/json',
- Authorization: `Bearer ${token}`,
- };
- const res = await axios
- .post(config.taasJobApiUrl, data, { headers })
- .catch((err) => {
- const error = new Error();
- error.message = _.get(err, 'response.data.message', error.message);
- throw error;
- });
- return res.data;
-};
-
/**
* Payload for deprecated BUS events like `connect.notification.project.updated`.
*/
@@ -186,42 +164,6 @@ async function projectCreatedKafkaHandler(app, topic, payload) {
await Promise.all(topicPromises);
app.logger.debug('Topics for phases are successfully created.');
}
- try {
- if (project.type === 'talent-as-a-service') {
- const jobs = _.get(project, 'details.taasDefinition.taasJobs');
- if (!jobs || !jobs.length) {
- app.logger.debug(`no jobs found in the project id: ${project.id}`);
- return;
- }
- app.logger.debug(`${jobs.length} jobs found in the project id: ${project.id}`);
- await Promise.all(
- _.map(
- jobs,
- (job) => {
- // make sure that skills would be unique in the list and only include ones with 'skillId' (actually they all suppose to be with skillId)
- const skills = _.chain(job.skills).map('skillId').uniq().compact()
- .value();
- return createTaasJob({
- projectId: project.id,
- title: job.title,
- description: job.description,
- skills,
- numPositions: Number(job.people),
- resourceType: _.get(job, 'role.value', ''),
- rateType: 'weekly', // hardcode for now
- workload: _.get(job, 'workLoad.title', '').toLowerCase(),
- }).then((createdJob) => {
- app.logger.debug(`jobId: ${createdJob.id} job created with title "${createdJob.title}"`);
- }).catch((err) => {
- app.logger.error(`Unable to create job with title "${job.title}": ${err.message}`);
- });
- },
- ),
- );
- }
- } catch (error) {
- app.logger.error(`Error while creating TaaS jobs: ${error}`);
- }
}
module.exports = {
diff --git a/src/events/projects/postTaasJobs.js b/src/events/projects/postTaasJobs.js
new file mode 100644
index 00000000..f2bedfff
--- /dev/null
+++ b/src/events/projects/postTaasJobs.js
@@ -0,0 +1,73 @@
+/**
+ * Event handler for the creation of `talent-as-a-service` projects.
+ */
+
+import _ from 'lodash';
+import config from 'config';
+import axios from 'axios';
+
+/**
+ * Create taas job.
+ *
+ * @param {String} authHeader the authorization header
+ * @param {Object} data the job data
+ * @return {Object} the job created
+ */
+async function createTaasJob(authHeader, data) {
+ const headers = {
+ 'Content-Type': 'application/json',
+ Authorization: authHeader,
+ };
+ const res = await axios
+ .post(config.taasJobApiUrl, data, { headers })
+ .catch((err) => {
+ const error = new Error();
+ error.message = _.get(err, 'response.data.message', error.message);
+ throw error;
+ });
+ return res.data;
+}
+
+/**
+ * Create taas jobs from project of type `talent-as-a-service` using the token from current user.
+ *
+ * @param {Object} req the request object
+ * @param {Object} project the project data
+ * @param {Object} logger the logger object
+ * @return {Object} the taas jobs created
+ */
+async function createTaasJobsFromProject(req, project, logger) {
+ const jobs = _.get(project, 'details.taasDefinition.taasJobs');
+ if (!jobs || !jobs.length) {
+ logger.debug(`no jobs found in the project id: ${project.id}`);
+ return;
+ }
+ logger.debug(`${jobs.length} jobs found in the project id: ${project.id}`);
+ await Promise.all(
+ _.map(
+ jobs,
+ (job) => {
+ // make sure that skills would be unique in the list and only include ones with 'skillId' (actually they all suppose to be with skillId)
+ const skills = _.chain(job.skills).map('skillId').uniq().compact()
+ .value();
+ return createTaasJob(req.headers.authorization, {
+ projectId: project.id,
+ title: job.title,
+ description: job.description,
+ duration: Number(job.duration),
+ skills,
+ numPositions: Number(job.people),
+ resourceType: _.get(job, 'role.value', ''),
+ rateType: 'weekly', // hardcode for now
+ workload: _.get(job, 'workLoad.title', '').toLowerCase(),
+ }).then((createdJob) => {
+ logger.debug(`jobId: ${createdJob.id} job created with title "${createdJob.title}"`);
+ }).catch((err) => {
+ logger.error(`Unable to create job with title "${job.title}": ${err.message}`);
+ });
+ },
+ ),
+ );
+}
+
+module.exports = createTaasJobsFromProject;
diff --git a/src/permissions/constants.js b/src/permissions/constants.js
index aa9f4ee9..41b7d3be 100644
--- a/src/permissions/constants.js
+++ b/src/permissions/constants.js
@@ -91,12 +91,20 @@ const SCOPES_PROJECTS_WRITE = [
M2M_SCOPES.PROJECTS.WRITE,
];
+/**
+ * M2M scopes to "read" available Billing Accounts for the project
+ */
+const SCOPES_PROJECTS_READ_AVL_BILLING_ACCOUNTS = [
+ M2M_SCOPES.CONNECT_PROJECT_ADMIN,
+ M2M_SCOPES.READ_USER_BILLING_ACCOUNTS,
+];
+
/**
* M2M scopes to "write" billingAccountId property
*/
-const SCOPES_PROJECTS_WRITE_BILLING_ACCOUNTS = [
+const SCOPES_PROJECTS_WRITE_PROJECTS_BILLING_ACCOUNTS = [
M2M_SCOPES.CONNECT_PROJECT_ADMIN,
- M2M_SCOPES.PROJECTS.WRITE_BILLING_ACCOUNTS,
+ M2M_SCOPES.PROJECTS.WRITE_PROJECTS_BILLING_ACCOUNTS,
];
/**
@@ -231,7 +239,7 @@ export const PERMISSION = { // eslint-disable-line import/prefer-default-export
USER_ROLE.MANAGER,
USER_ROLE.TOPCODER_ADMIN,
],
- scopes: SCOPES_PROJECTS_WRITE_BILLING_ACCOUNTS,
+ scopes: SCOPES_PROJECTS_WRITE_PROJECTS_BILLING_ACCOUNTS,
},
DELETE_PROJECT: {
@@ -252,6 +260,23 @@ export const PERMISSION = { // eslint-disable-line import/prefer-default-export
scopes: SCOPES_PROJECTS_WRITE,
},
+ /*
+ * Project Invite
+ */
+ READ_AVL_PROJECT_BILLING_ACCOUNTS: {
+ meta: {
+ title: 'Read Available Project Billing Accounts',
+ group: 'Project Billing Accounts',
+ description: 'Who can view the Billing Accounts available for the project',
+ },
+ projectRoles: [
+ ...PROJECT_ROLES_MANAGEMENT,
+ PROJECT_MEMBER_ROLE.COPILOT,
+ ],
+ topcoderRoles: TOPCODER_ROLES_ADMINS,
+ scopes: SCOPES_PROJECTS_READ_AVL_BILLING_ACCOUNTS,
+ },
+
/*
* Project Member
*/
diff --git a/src/permissions/index.js b/src/permissions/index.js
index a37fdb04..2e62dc7f 100644
--- a/src/permissions/index.js
+++ b/src/permissions/index.js
@@ -20,6 +20,10 @@ module.exports = () => {
Authorizer.setPolicy('project.edit', generalPermission(PERMISSION.UPDATE_PROJECT));
Authorizer.setPolicy('project.delete', generalPermission(PERMISSION.DELETE_PROJECT));
+ Authorizer.setPolicy('projectBillingAccounts.view', generalPermission([
+ PERMISSION.READ_AVL_PROJECT_BILLING_ACCOUNTS,
+ ]));
+
Authorizer.setPolicy('projectMember.create', generalPermission([
PERMISSION.CREATE_PROJECT_MEMBER_OWN,
PERMISSION.CREATE_PROJECT_MEMBER_NOT_OWN,
diff --git a/src/routes/billingAccounts/list.js b/src/routes/billingAccounts/list.js
new file mode 100644
index 00000000..77166d56
--- /dev/null
+++ b/src/routes/billingAccounts/list.js
@@ -0,0 +1,39 @@
+// import _ from 'lodash';
+import validate from 'express-validation';
+import Joi from 'joi';
+import { middleware as tcMiddleware } from 'tc-core-library-js';
+import SalesforceService from '../../services/salesforceService';
+
+/**
+ * API to get project attachments.
+ *
+ */
+
+const permissions = tcMiddleware.permissions;
+
+const schema = {
+ params: {
+ projectId: Joi.number().integer().positive().required(),
+ },
+};
+
+module.exports = [
+ validate(schema),
+ permissions('projectBillingAccounts.view'),
+ async (req, res, next) => {
+ // const projectId = _.parseInt(req.params.projectId);
+ const userId = req.authUser.userId;
+ try {
+ const { accessToken, instanceUrl } = await SalesforceService.authenticate();
+ // eslint-disable-next-line
+ const sql = `SELECT Topcoder_Billing_Account__r.id, Topcoder_Billing_Account__r.TopCoder_Billing_Account_Id__c, Topcoder_Billing_Account__r.Billing_Account_Name__c, Topcoder_Billing_Account__r.Start_Date__c, Topcoder_Billing_Account__r.End_Date__c from Topcoder_Billing_Account_Resource__c tbar where UserID__c='${userId}'`;
+ // and Topcoder_Billing_Account__r.TC_Connect_Project_ID__c='${projectId}'
+ req.log.debug(sql);
+ const billingAccounts = await SalesforceService.query(sql, accessToken, instanceUrl, req.log);
+ res.json(billingAccounts);
+ } catch (error) {
+ req.log.error(error);
+ next(error);
+ }
+ },
+];
diff --git a/src/routes/billingAccounts/list.spec.js b/src/routes/billingAccounts/list.spec.js
new file mode 100644
index 00000000..bd76869c
--- /dev/null
+++ b/src/routes/billingAccounts/list.spec.js
@@ -0,0 +1,182 @@
+/* eslint-disable no-unused-expressions */
+import chai from 'chai';
+import request from 'supertest';
+import sinon from 'sinon';
+
+import models from '../../models';
+import server from '../../app';
+import testUtil from '../../tests/util';
+import SalesforceService from '../../services/salesforceService';
+
+chai.should();
+
+// demo data which might be returned by the `SalesforceService.query`
+const billingAccountsData = [
+ {
+ sfBillingAccountId: '123',
+ tcBillingAccountId: 123123,
+ name: 'Billing Account 1',
+ startDate: '2021-02-10T18:51:27Z',
+ endDate: '2021-03-10T18:51:27Z',
+ }, {
+ sfBillingAccountId: '456',
+ tcBillingAccountId: 456456,
+ name: 'Billing Account 2',
+ startDate: '2011-02-10T18:51:27Z',
+ endDate: '2011-03-10T18:51:27Z',
+ },
+];
+
+describe('Project Billing Accounts list', () => {
+ let project1;
+ let salesforceAuthenticate;
+ let salesforceQuery;
+
+ beforeEach((done) => {
+ testUtil.clearDb()
+ .then(() => testUtil.clearES())
+ .then(() => {
+ models.Project.create({
+ type: 'generic',
+ directProjectId: 1,
+ billingAccountId: 1,
+ name: 'test1',
+ description: 'test project1',
+ status: 'draft',
+ details: {},
+ createdBy: 1,
+ updatedBy: 1,
+ lastActivityAt: 1,
+ lastActivityUserId: '1',
+ }).then((p) => {
+ project1 = p;
+ // create members
+ return models.ProjectMember.create({
+ userId: testUtil.userIds.copilot,
+ projectId: project1.id,
+ role: 'copilot',
+ isPrimary: true,
+ createdBy: 1,
+ updatedBy: 1,
+ }).then(() => models.ProjectMember.create({
+ userId: testUtil.userIds.member,
+ projectId: project1.id,
+ role: 'customer',
+ isPrimary: false,
+ createdBy: 1,
+ updatedBy: 1,
+ }));
+ }).then(() => {
+ salesforceAuthenticate = sinon.stub(SalesforceService, 'authenticate', () => Promise.resolve({
+ accessToken: 'mock',
+ instanceUrl: 'mock_url',
+ }));
+ salesforceQuery = sinon.stub(SalesforceService, 'query', () => Promise.resolve(billingAccountsData));
+ done();
+ });
+ });
+ });
+
+ afterEach((done) => {
+ salesforceAuthenticate.restore();
+ salesforceQuery.restore();
+ done();
+ });
+
+ after((done) => {
+ testUtil.clearDb(done);
+ });
+
+ describe('List /projects/{id}/billingAccounts', () => {
+ it('should return 403 for anonymous user', (done) => {
+ request(server)
+ .get(`/v5/projects/${project1.id}/billingAccounts`)
+ .expect(403, done);
+ });
+
+ it('should return 403 for a customer user who is a member of the project', (done) => {
+ request(server)
+ .get(`/v5/projects/${project1.id}/billingAccounts`)
+ .set({
+ Authorization: `Bearer ${testUtil.jwts.member}`,
+ })
+ .send()
+ .expect(403, done);
+ });
+
+ it('should return 403 for a topcoder user who is not a member of the project', (done) => {
+ request(server)
+ .get(`/v5/projects/${project1.id}/billingAccounts`)
+ .set({
+ Authorization: `Bearer ${testUtil.jwts.copilotManager}`,
+ })
+ .send()
+ .expect(403, done);
+ });
+
+ it('should return all billing accounts for a topcoder user who is a member of the project', (done) => {
+ request(server)
+ .get(`/v5/projects/${project1.id}/billingAccounts`)
+ .set({
+ Authorization: `Bearer ${testUtil.jwts.copilot}`,
+ })
+ .send()
+ .expect(200)
+ .end((err, res) => {
+ if (err) {
+ done(err);
+ } else {
+ const resJson = res.body;
+ resJson.should.have.length(2);
+ resJson.should.include(billingAccountsData[0]);
+ resJson.should.include(billingAccountsData[1]);
+ done();
+ }
+ });
+ });
+
+ it('should return all billing accounts to admin', (done) => {
+ request(server)
+ .get(`/v5/projects/${project1.id}/billingAccounts`)
+ .set({
+ Authorization: `Bearer ${testUtil.jwts.admin}`,
+ })
+ .send()
+ .expect(200)
+ .end((err, res) => {
+ if (err) {
+ done(err);
+ } else {
+ const resJson = res.body;
+ resJson.should.have.length(2);
+ resJson.should.have.length(2);
+ resJson.should.include(billingAccountsData[0]);
+ resJson.should.include(billingAccountsData[1]);
+ done();
+ }
+ });
+ });
+
+ it('should return all billing accounts using M2M token with "read:user-billing-accounts" scope', (done) => {
+ request(server)
+ .get(`/v5/projects/${project1.id}/billingAccounts`)
+ .set({
+ Authorization: `Bearer ${testUtil.m2m['read:user-billing-accounts']}`,
+ })
+ .send()
+ .expect(200)
+ .end((err, res) => {
+ if (err) {
+ done(err);
+ } else {
+ const resJson = res.body;
+ resJson.should.have.length(2);
+ resJson.should.have.length(2);
+ resJson.should.include(billingAccountsData[0]);
+ resJson.should.include(billingAccountsData[1]);
+ done();
+ }
+ });
+ });
+ });
+});
diff --git a/src/routes/index.js b/src/routes/index.js
index 29a180a0..5a28ee64 100644
--- a/src/routes/index.js
+++ b/src/routes/index.js
@@ -121,6 +121,9 @@ router.route('/v5/projects/:projectId(\\d+)/scopeChangeRequests/:requestId(\\d+)
.patch(require('./scopeChangeRequests/update'));
// .delete(require('./scopeChangeRequests/delete'));
+router.route('/v5/projects/:projectId(\\d+)/billingAccounts')
+ .get(require('./billingAccounts/list'));
+
router.route('/v5/projects/:projectId(\\d+)/members')
.get(require('./projectMembers/list'))
.post(require('./projectMembers/create'));
diff --git a/src/routes/projectMembers/create.js b/src/routes/projectMembers/create.js
index 1d15991a..fd35fd26 100644
--- a/src/routes/projectMembers/create.js
+++ b/src/routes/projectMembers/create.js
@@ -80,6 +80,13 @@ module.exports = [
newMember = await util.addUserToProject(req, member, transaction);
});
+ try {
+ const fields = req.query.fields ? req.query.fields.split(',') : null;
+ [newMember] = await util.getObjectsWithMemberDetails([newMember], fields, req);
+ } catch (err) {
+ req.log.error('Cannot get user details for member.');
+ req.log.debug('Error during getting user details for member.', err);
+ }
return res.status(201).json(newMember);
} catch (err) {
return next(err);
diff --git a/src/services/salesforceService.js b/src/services/salesforceService.js
new file mode 100644
index 00000000..09dc66e2
--- /dev/null
+++ b/src/services/salesforceService.js
@@ -0,0 +1,82 @@
+/**
+ * Represents the Salesforce service
+ */
+import _ from 'lodash';
+import config from 'config';
+import jwt from 'jsonwebtoken';
+import util from '../util';
+
+const axios = require('axios');
+
+const loginBaseUrl = config.salesforce.CLIENT_AUDIENCE || 'https://login.salesforce.com';
+// we are using dummy private key to fail safe when key is not provided in env
+let privateKey = config.salesforce.CLIENT_KEY || 'privateKey';
+privateKey = privateKey.replace(/\\n/g, '\n');
+
+const urlEncodeForm = k =>
+ Object.keys(k).reduce((a, b) => `${a}&${b}=${encodeURIComponent(k[b])}`, '');
+
+/**
+ * Helper class to abstract salesforce API calls
+ */
+class SalesforceService {
+ /**
+ * Authenticate to Salesforce with pre-configured credentials
+ * @returns {{accessToken: String, instanceUrl: String}} the result
+ */
+ static authenticate() {
+ const jwtToken = jwt.sign({}, privateKey, {
+ expiresIn: '1h', // any expiration
+ issuer: config.salesforce.CLIENT_ID,
+ audience: config.salesforce.CLIENT_AUDIENCE,
+ subject: config.salesforce.SUBJECT,
+ algorithm: 'RS256',
+ });
+ return axios({
+ method: 'post',
+ url: `${loginBaseUrl}/services/oauth2/token`,
+ data: urlEncodeForm({
+ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
+ assertion: jwtToken,
+ }),
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ }).then(res => ({
+ accessToken: res.data.access_token,
+ instanceUrl: res.data.instance_url,
+ }));
+ }
+
+ /**
+ * Run the query statement
+ * @param {String} sql the Saleforce sql statement
+ * @param {String} accessToken the access token
+ * @param {String} instanceUrl the salesforce instance url
+ * @param {Object} logger logger to be used for logging
+ * @returns {{totalSize: Number, done: Boolean, records: Array}} the result
+ */
+ static query(sql, accessToken, instanceUrl, logger) {
+ return axios({
+ url: `${instanceUrl}/services/data/v37.0/query?q=${sql}`,
+ method: 'get',
+ headers: { authorization: `Bearer ${accessToken}` },
+ }).then((res) => {
+ if (logger) {
+ logger.debug(_.get(res, 'data.records', []));
+ }
+ const billingAccounts = _.get(res, 'data.records', []).map(o => ({
+ sfBillingAccountId: _.get(o, 'Topcoder_Billing_Account__r.Id'),
+ tcBillingAccountId: util.parseIntStrictly(
+ _.get(o, 'Topcoder_Billing_Account__r.TopCoder_Billing_Account_Id__c'),
+ 10,
+ null, // fallback to null if cannot parse
+ ),
+ name: _.get(o, 'Topcoder_Billing_Account__r.Billing_Account_Name__c'),
+ startDate: _.get(o, 'Topcoder_Billing_Account__r.Start_Date__c'),
+ endDate: _.get(o, 'Topcoder_Billing_Account__r.End_Date__c'),
+ }));
+ return billingAccounts;
+ });
+ }
+}
+
+export default SalesforceService;
diff --git a/src/tests/util.js b/src/tests/util.js
index a98e7b5c..85a4bc64 100644
--- a/src/tests/util.js
+++ b/src/tests/util.js
@@ -39,6 +39,8 @@ export default {
},
m2m: {
[M2M_SCOPES.CONNECT_PROJECT_ADMIN]: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL3RvcGNvZGVyLWRldi5hdXRoMC5jb20vIiwic3ViIjoidGVzdEBjbGllbnRzIiwiYXVkIjoiaHR0cHM6Ly9tMm0udG9wY29kZXItZGV2LmNvbS8iLCJpYXQiOjE1ODc3MzI0NTksImV4cCI6MjU4NzgxODg1OSwiYXpwIjoidGVzdCIsInNjb3BlIjoiYWxsOmNvbm5lY3RfcHJvamVjdCIsImd0eSI6ImNsaWVudC1jcmVkZW50aWFscyJ9.q34b2IC1pw3ksl5RtnSEW5_HGwN0asx2MD3LV9-Wffg',
+ // TODO update token to have correct scope, as of now it is copied from CONNECT_PROJECT_ADMIN
+ [M2M_SCOPES.PROJECTS.READ_USER_BILLING_ACCOUNTS]: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL3RvcGNvZGVyLWRldi5hdXRoMC5jb20vIiwic3ViIjoidGVzdEBjbGllbnRzIiwiYXVkIjoiaHR0cHM6Ly9tMm0udG9wY29kZXItZGV2LmNvbS8iLCJpYXQiOjE1ODc3MzI0NTksImV4cCI6MjU4NzgxODg1OSwiYXpwIjoidGVzdCIsInNjb3BlIjoiYWxsOmNvbm5lY3RfcHJvamVjdCIsImd0eSI6ImNsaWVudC1jcmVkZW50aWFscyJ9.q34b2IC1pw3ksl5RtnSEW5_HGwN0asx2MD3LV9-Wffg',
[M2M_SCOPES.PROJECTS.ALL]: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL3RvcGNvZGVyLWRldi5hdXRoMC5jb20vIiwic3ViIjoidGVzdEBjbGllbnRzIiwiYXVkIjoiaHR0cHM6Ly9tMm0udG9wY29kZXItZGV2LmNvbS8iLCJpYXQiOjE1ODc3MzI0NTksImV4cCI6MjU4NzgxODg1OSwiYXpwIjoidGVzdCIsInNjb3BlIjoiYWxsOnByb2plY3RzIiwiZ3R5IjoiY2xpZW50LWNyZWRlbnRpYWxzIn0.ixFXMCsBmIN9mQ9Z3s-Apkg20A3d86Pm9RouL7bZMV4',
[M2M_SCOPES.PROJECTS.READ]: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL3RvcGNvZGVyLWRldi5hdXRoMC5jb20vIiwic3ViIjoidGVzdEBjbGllbnRzIiwiYXVkIjoiaHR0cHM6Ly9tMm0udG9wY29kZXItZGV2LmNvbS8iLCJpYXQiOjE1ODc3MzI0NTksImV4cCI6MjU4NzgxODg1OSwiYXpwIjoidGVzdCIsInNjb3BlIjoicmVhZDpwcm9qZWN0cyIsImd0eSI6ImNsaWVudC1jcmVkZW50aWFscyJ9.IpYgfbem-eR6tGjBoxQBPDw6YIulBTZLBn48NuyJT_g',
[M2M_SCOPES.PROJECTS.WRITE]: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL3RvcGNvZGVyLWRldi5hdXRoMC5jb20vIiwic3ViIjoidGVzdEBjbGllbnRzIiwiYXVkIjoiaHR0cHM6Ly9tMm0udG9wY29kZXItZGV2LmNvbS8iLCJpYXQiOjE1ODc3MzI0NTksImV4cCI6MjU4NzgxODg1OSwiYXpwIjoidGVzdCIsInNjb3BlIjoid3JpdGU6cHJvamVjdHMiLCJndHkiOiJjbGllbnQtY3JlZGVudGlhbHMifQ.cAMbmnSKXB8Xl4s4Nlo1LduPySBcvKz2Ygilq5b0OD0',
diff --git a/src/util.js b/src/util.js
index 1cdd8cfd..a5ab739a 100644
--- a/src/util.js
+++ b/src/util.js
@@ -1473,6 +1473,34 @@ const projectServiceUtils = {
});
},
+ /**
+ * Parse integer value inside string or return fallback value.
+ * Unlike original parseInt, this method parses the whole string
+ * and fails if there are non-integer characters inside the string.
+ *
+ * @param {String} str number to parse
+ * @param {Number} radix radix of the number to parse
+ * @param {Any} fallbackValue value to return if we cannot parse successfully
+ *
+ * @returns {Number} parsed number
+ */
+ parseIntStrictly: (str, radix, fallbackValue) => {
+ const int = parseInt(str, radix);
+
+ if (_.isNaN(int)) {
+ return fallbackValue;
+ }
+
+ // if we parsed only the part of value and it's not the same as intial value
+ // example: "12x" => 12 which is not the same as initial value "12x", which means
+ // we cannot parse the full value sucessfully and treat it like we cannot parse at all
+ if (int.toString() !== str) {
+ return fallbackValue;
+ }
+
+ return int;
+ },
+
};
_.assignIn(util, projectServiceUtils);
diff --git a/src/util.spec.js b/src/util.spec.js
index 0fe574ed..e1a9ba8d 100644
--- a/src/util.spec.js
+++ b/src/util.spec.js
@@ -1,12 +1,38 @@
/**
* Tests for util.js
*/
-import chai from 'chai';
+import chai, { expect } from 'chai';
import util from './util';
chai.should();
describe('Util method', () => {
+ describe('parseIntStrictly', () => {
+ it('should parse a good integer value sucessfully', () => {
+ util.parseIntStrictly('1234567890', 10, null).should.equal(1234567890);
+ });
+
+ it('should return fallback value if the initial value is a float number', () => {
+ expect(util.parseIntStrictly('1.1', 10, null)).be.equal(null);
+ });
+
+ it('should return fallback value if string can be parsed partially only', () => {
+ expect(util.parseIntStrictly('123XXX', 10, null)).be.equal(null);
+ });
+
+ it('should return fallback value if the initial value is `null`', () => {
+ util.parseIntStrictly(null, 10, 0).should.equal(0);
+ });
+
+ it('should return fallback value if the initial value is `undefined`', () => {
+ expect(util.parseIntStrictly(undefined, 10, null)).be.equal(null);
+ });
+
+ it('should return fallback value if the initial value is `""` (emtpy string)', () => {
+ expect(util.parseIntStrictly('', 10, null)).be.equal(null);
+ });
+ });
+
describe('maskEmail', () => {
it('should return the original value if the email is non-string', () => {
chai.should().not.exist(util.maskEmail(null));