Skip to content

Commit 76d88f9

Browse files
author
Vikas Agarwal
committed
feat: git#620-New API endpoint to bring Billing Accounts available to the logged in user
- Initial draft
1 parent 8b17f53 commit 76d88f9

File tree

10 files changed

+295
-2
lines changed

10 files changed

+295
-2
lines changed

config/custom-environment-variables.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,5 +70,11 @@
7070
"EMBED_REPORTS_MAPPING": "EMBED_REPORTS_MAPPING",
7171
"ALLOWED_USERS": "REPORTS_ALLOWED_USERS"
7272
},
73-
"DEFAULT_M2M_USERID": "DEFAULT_M2M_USERID"
73+
"DEFAULT_M2M_USERID": "DEFAULT_M2M_USERID",
74+
"salesforce": {
75+
"CLIENT_AUDIENCE": "SALESFORCE_AUDIENCE",
76+
"CLIENT_KEY": "SALESFORCE_CLIENT_KEY",
77+
"SUBJECT": "SALESFORCE_SUBJECT",
78+
"CLIENT_ID": "SALESFORCE_CLIENT_ID"
79+
}
7480
}

config/default.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,5 +76,11 @@
7676
"ALLOWED_USERS": "[]"
7777
},
7878
"DEFAULT_M2M_USERID": -101,
79-
"taasJobApiUrl": "https://api.topcoder.com/v5/jobs"
79+
"taasJobApiUrl": "https://api.topcoder.com/v5/jobs",
80+
"salesforce": {
81+
"CLIENT_KEY": "",
82+
"CLIENT_AUDIENCE": "",
83+
"SUBJECT": "",
84+
"CLIENT_ID": ""
85+
}
8086
}

src/constants.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,7 @@ export const M2M_SCOPES = {
274274
ALL: 'all:projects',
275275
READ: 'read:projects',
276276
WRITE: 'write:projects',
277+
READ_BILLING_ACCOUNTS: 'read:user-billing-accounts',
277278
WRITE_BILLING_ACCOUNTS: 'write:projects-billing-accounts',
278279
},
279280
PROJECT_MEMBERS: {

src/permissions/constants.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,15 @@ const SCOPES_PROJECTS_WRITE = [
9191
M2M_SCOPES.PROJECTS.WRITE,
9292
];
9393

94+
/**
95+
* M2M scopes to "read" available Billing Accounts for the project
96+
*/
97+
const SCOPES_PROJECTS_READ_AVL_BILLING_ACCOUNTS = [
98+
M2M_SCOPES.CONNECT_PROJECT_ADMIN,
99+
M2M_SCOPES.READ_BILLING_ACCOUNTS,
100+
M2M_SCOPES.PROJECTS.ALL,
101+
];
102+
94103
/**
95104
* M2M scopes to "write" billingAccountId property
96105
*/
@@ -252,6 +261,19 @@ export const PERMISSION = { // eslint-disable-line import/prefer-default-export
252261
scopes: SCOPES_PROJECTS_WRITE,
253262
},
254263

264+
/*
265+
* Project Invite
266+
*/
267+
READ_AVL_PROJECT_BILLING_ACCOUNTS: {
268+
meta: {
269+
title: 'Read Available Project Billing Accounts',
270+
group: 'Project Billing Accounts',
271+
description: 'Who can view the Billing Accounts available for the project',
272+
},
273+
topcoderRoles: ALL,
274+
scopes: SCOPES_PROJECTS_READ_AVL_BILLING_ACCOUNTS,
275+
},
276+
255277
/*
256278
* Project Member
257279
*/

src/permissions/index.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ module.exports = () => {
2020
Authorizer.setPolicy('project.edit', generalPermission(PERMISSION.UPDATE_PROJECT));
2121
Authorizer.setPolicy('project.delete', generalPermission(PERMISSION.DELETE_PROJECT));
2222

23+
Authorizer.setPolicy('projectBillingAccounts.view', generalPermission([
24+
PERMISSION.READ_AVL_PROJECT_BILLING_ACCOUNTS,
25+
]));
26+
2327
Authorizer.setPolicy('projectMember.create', generalPermission([
2428
PERMISSION.CREATE_PROJECT_MEMBER_OWN,
2529
PERMISSION.CREATE_PROJECT_MEMBER_NOT_OWN,

src/routes/billingAccounts/list.js

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// import _ from 'lodash';
2+
import validate from 'express-validation';
3+
import Joi from 'joi';
4+
import { middleware as tcMiddleware } from 'tc-core-library-js';
5+
import SalesforceService from '../../services/salesforceService';
6+
7+
/**
8+
* API to get project attachments.
9+
*
10+
*/
11+
12+
const permissions = tcMiddleware.permissions;
13+
14+
const schema = {
15+
params: {
16+
projectId: Joi.number().integer().positive().required(),
17+
},
18+
};
19+
20+
module.exports = [
21+
validate(schema),
22+
permissions('projectBillingAccounts.view'),
23+
async (req, res, next) => {
24+
// const projectId = _.parseInt(req.params.projectId);
25+
const userId = req.authUser.userId;
26+
try {
27+
const { accessToken, instanceUrl } = await SalesforceService.authenticate();
28+
// eslint-disable-next-line
29+
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}'`;
30+
// and Topcoder_Billing_Account__r.TC_Connect_Project_ID__c='${projectId}'
31+
req.log.debug(sql);
32+
const billingAccounts = await SalesforceService.query(sql, accessToken, instanceUrl, req.log);
33+
res.json(billingAccounts);
34+
} catch (error) {
35+
req.log.error(error);
36+
next(error);
37+
}
38+
},
39+
];
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
/* eslint-disable no-unused-expressions */
2+
// import chai from 'chai';
3+
import request from 'supertest';
4+
import sinon from 'sinon';
5+
6+
import models from '../../models';
7+
import server from '../../app';
8+
import testUtil from '../../tests/util';
9+
import SalesforceService from '../../services/salesforceService';
10+
11+
// const should = chai.should();
12+
13+
describe('Project Billing Accounts list', () => {
14+
let project1;
15+
let salesforceAuthenticate;
16+
let salesforceQuery;
17+
18+
beforeEach((done) => {
19+
testUtil.clearDb()
20+
.then(() => testUtil.clearES())
21+
.then(() => {
22+
models.Project.create({
23+
type: 'generic',
24+
directProjectId: 1,
25+
billingAccountId: 1,
26+
name: 'test1',
27+
description: 'test project1',
28+
status: 'draft',
29+
details: {},
30+
createdBy: 1,
31+
updatedBy: 1,
32+
lastActivityAt: 1,
33+
lastActivityUserId: '1',
34+
}).then((p) => {
35+
project1 = p;
36+
// create members
37+
return models.ProjectMember.create({
38+
userId: testUtil.userIds.copilot,
39+
projectId: project1.id,
40+
role: 'copilot',
41+
isPrimary: true,
42+
createdBy: 1,
43+
updatedBy: 1,
44+
}).then(() => models.ProjectMember.create({
45+
userId: testUtil.userIds.member,
46+
projectId: project1.id,
47+
role: 'customer',
48+
isPrimary: false,
49+
createdBy: 1,
50+
updatedBy: 1,
51+
}));
52+
}).then(() => {
53+
salesforceAuthenticate = sinon.stub(SalesforceService, 'authenticate', () => Promise.resolve({
54+
accessToken: 'mock',
55+
instanceUrl: 'mock_url',
56+
}));
57+
salesforceQuery = sinon.stub(SalesforceService, 'query', () => Promise.resolve([{
58+
accessToken: 'mock',
59+
instanceUrl: 'mock_url',
60+
}]));
61+
done();
62+
});
63+
});
64+
});
65+
66+
afterEach((done) => {
67+
salesforceAuthenticate.restore();
68+
salesforceQuery.restore();
69+
done();
70+
});
71+
72+
after((done) => {
73+
testUtil.clearDb(done);
74+
});
75+
76+
describe('List /projects/{id}/billingAccounts', () => {
77+
it('should return 403 for anonymous user', (done) => {
78+
request(server)
79+
.get(`/v5/projects/${project1.id}/billingAccounts`)
80+
.expect(403, done);
81+
});
82+
83+
it('should return 403 for a regular user who is not a member of the project', (done) => {
84+
request(server)
85+
.get(`/v5/projects/${project1.id}/billingAccounts`)
86+
.set({
87+
Authorization: `Bearer ${testUtil.jwts.member2}`,
88+
})
89+
.send()
90+
.expect(403, done);
91+
});
92+
93+
it('should return all attachments to admin', (done) => {
94+
request(server)
95+
.get(`/v5/projects/${project1.id}/billingAccounts`)
96+
.set({
97+
Authorization: `Bearer ${testUtil.jwts.admin}`,
98+
})
99+
.send()
100+
.expect(200)
101+
.end((err, res) => {
102+
if (err) {
103+
done(err);
104+
} else {
105+
const resJson = res.body;
106+
resJson.should.have.length(2);
107+
// TODO verify BA fields
108+
done();
109+
}
110+
});
111+
});
112+
113+
xit('should return all attachments using M2M token with "read:user-billing-accounts" scope', (done) => {
114+
request(server)
115+
.get(`/v5/projects/${project1.id}/billingAccounts`)
116+
.set({
117+
Authorization: `Bearer ${testUtil.m2m['read:user-billing-accounts']}`,
118+
})
119+
.send()
120+
.expect(200)
121+
.end((err, res) => {
122+
if (err) {
123+
done(err);
124+
} else {
125+
const resJson = res.body;
126+
resJson.should.have.length(2);
127+
// TODO verify BA fields
128+
done();
129+
}
130+
});
131+
});
132+
});
133+
});

src/routes/index.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,9 @@ router.route('/v5/projects/:projectId(\\d+)/scopeChangeRequests/:requestId(\\d+)
121121
.patch(require('./scopeChangeRequests/update'));
122122
// .delete(require('./scopeChangeRequests/delete'));
123123

124+
router.route('/v5/projects/:projectId(\\d+)/billingAccounts')
125+
.get(require('./billingAccounts/list'));
126+
124127
router.route('/v5/projects/:projectId(\\d+)/members')
125128
.get(require('./projectMembers/list'))
126129
.post(require('./projectMembers/create'));

src/services/salesforceService.js

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/**
2+
* Represents the Salesforce service
3+
*/
4+
import _ from 'lodash';
5+
import config from 'config';
6+
import jwt from 'jsonwebtoken';
7+
8+
const axios = require('axios');
9+
10+
const loginBaseUrl = config.salesforce.CLIENT_AUDIENCE || 'https://login.salesforce.com';
11+
// we are using dummy private key to fail safe when key is not provided in env
12+
let privateKey = config.salesforce.CLIENT_KEY || 'privateKey';
13+
privateKey = privateKey.replace(/\\n/g, '\n');
14+
15+
const urlEncodeForm = k =>
16+
Object.keys(k).reduce((a, b) => `${a}&${b}=${encodeURIComponent(k[b])}`, '');
17+
18+
/**
19+
* Helper class to abstract salesforce API calls
20+
*/
21+
class SalesforceService {
22+
/**
23+
* Authenticate to Salesforce with pre-configured credentials
24+
* @returns {{accessToken: String, instanceUrl: String}} the result
25+
*/
26+
static authenticate() {
27+
const jwtToken = jwt.sign({}, privateKey, {
28+
expiresIn: '1h', // any expiration
29+
issuer: config.salesforce.CLIENT_ID,
30+
audience: config.salesforce.CLIENT_AUDIENCE,
31+
subject: config.salesforce.SUBJECT,
32+
algorithm: 'RS256',
33+
});
34+
return axios({
35+
method: 'post',
36+
url: `${loginBaseUrl}/services/oauth2/token`,
37+
data: urlEncodeForm({
38+
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
39+
assertion: jwtToken,
40+
}),
41+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
42+
}).then(res => ({
43+
accessToken: res.data.access_token,
44+
instanceUrl: res.data.instance_url,
45+
}));
46+
}
47+
48+
/**
49+
* Run the query statement
50+
* @param {String} sql the Saleforce sql statement
51+
* @param {String} accessToken the access token
52+
* @param {String} instanceUrl the salesforce instance url
53+
* @param {Object} logger logger to be used for logging
54+
* @returns {{totalSize: Number, done: Boolean, records: Array}} the result
55+
*/
56+
static query(sql, accessToken, instanceUrl, logger) {
57+
return axios({
58+
url: `${instanceUrl}/services/data/v37.0/query?q=${sql}`,
59+
method: 'get',
60+
headers: { authorization: `Bearer ${accessToken}` },
61+
}).then((res) => {
62+
if (logger) {
63+
logger.debug(_.get(res, 'data.records', []));
64+
}
65+
const billingAccounts = _.get(res, 'data.records', []).map(o => ({
66+
sfBillingAccountId: _.get(o, 'Topcoder_Billing_Account__r.Id'),
67+
tcBillingAccountId: _.get(o, 'Topcoder_Billing_Account__r.TopCoder_Billing_Account_Id__c'),
68+
name: _.get(o, 'Topcoder_Billing_Account__r.Billing_Account_Name__c'),
69+
startDate: _.get(o, 'Topcoder_Billing_Account__r.Start_Date__c'),
70+
endDate: _.get(o, 'Topcoder_Billing_Account__r.End_Date__c'),
71+
}));
72+
return billingAccounts;
73+
});
74+
}
75+
}
76+
77+
export default new SalesforceService();

src/tests/util.js

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)