ti2 1.0.102 → 1.0.104
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/api.yml +3 -0
- package/controllers/__tests__/bookings.js +52 -0
- package/controllers/__tests__/user.js +115 -0
- package/controllers/app.js +17 -13
- package/index.js +13 -0
- package/lib/corrupted-token-error.js +22 -0
- package/models/userappkey.js +18 -5
- package/package.json +1 -1
- package/test/plugin.js +4 -3
- package/tutorials/itinerary-assist-plugin-dev.md +4 -3
package/api.yml
CHANGED
|
@@ -451,6 +451,9 @@ components:
|
|
|
451
451
|
id:
|
|
452
452
|
type: string
|
|
453
453
|
description: a quote Id to make a reservation for
|
|
454
|
+
shellOnly:
|
|
455
|
+
type: boolean
|
|
456
|
+
description: Optional itinerary-assist flag to create/update booking header only without adding a service line
|
|
454
457
|
settingsBody:
|
|
455
458
|
type: object
|
|
456
459
|
required: true
|
|
@@ -32,6 +32,7 @@ describe('user: bookings controller', () => {
|
|
|
32
32
|
let userToken;
|
|
33
33
|
let appSetup;
|
|
34
34
|
let createUserToken;
|
|
35
|
+
let sqldb;
|
|
35
36
|
|
|
36
37
|
beforeAll(async () => {
|
|
37
38
|
({
|
|
@@ -41,6 +42,7 @@ describe('user: bookings controller', () => {
|
|
|
41
42
|
plugins,
|
|
42
43
|
appSetup,
|
|
43
44
|
createUserToken,
|
|
45
|
+
sqldb,
|
|
44
46
|
} = await testUtils({
|
|
45
47
|
plugins: [newApp.name],
|
|
46
48
|
}));
|
|
@@ -168,6 +170,56 @@ describe('user: bookings controller', () => {
|
|
|
168
170
|
expect(plugins[0].createBooking.mock.calls[0][0].payload).toEqual(payload);
|
|
169
171
|
expect(plugins[0].createBooking.mock.calls[0][0].token).toEqual(token);
|
|
170
172
|
});
|
|
173
|
+
it('should return 422 when stored app token is corrupted on bookings endpoint', async () => {
|
|
174
|
+
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
175
|
+
const [originalRows] = await sqldb.query(
|
|
176
|
+
'SELECT id, appKey FROM UserAppKeys WHERE integrationId = :integrationId AND userId = :userId AND hint = :hint',
|
|
177
|
+
{
|
|
178
|
+
replacements: {
|
|
179
|
+
integrationId: appKey,
|
|
180
|
+
userId,
|
|
181
|
+
hint: 'testingToken',
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
);
|
|
185
|
+
await sqldb.query(
|
|
186
|
+
'UPDATE UserAppKeys SET appKey = :appKey WHERE integrationId = :integrationId AND userId = :userId AND hint = :hint',
|
|
187
|
+
{
|
|
188
|
+
replacements: {
|
|
189
|
+
appKey: 'corrupted-token-without-delimiter',
|
|
190
|
+
integrationId: appKey,
|
|
191
|
+
userId,
|
|
192
|
+
hint: 'testingToken',
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
);
|
|
196
|
+
try {
|
|
197
|
+
const returnValue = await doApiPost({
|
|
198
|
+
url: `/bookings/${appKey}/${userId}/testingToken/search`,
|
|
199
|
+
token: userToken,
|
|
200
|
+
payload: {
|
|
201
|
+
bookingId: '',
|
|
202
|
+
supplierBookingId: '',
|
|
203
|
+
name: '',
|
|
204
|
+
},
|
|
205
|
+
expectStatusCode: 422,
|
|
206
|
+
});
|
|
207
|
+
expect(returnValue.message).toBe(
|
|
208
|
+
'Stored integration token is invalid. Please re-save credentials.',
|
|
209
|
+
);
|
|
210
|
+
} finally {
|
|
211
|
+
await Promise.all(originalRows.map(currentRow => sqldb.query(
|
|
212
|
+
'UPDATE UserAppKeys SET appKey = :appKey WHERE id = :id',
|
|
213
|
+
{
|
|
214
|
+
replacements: {
|
|
215
|
+
id: currentRow.id,
|
|
216
|
+
appKey: currentRow.appKey,
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
)));
|
|
220
|
+
consoleErrorSpy.mockRestore();
|
|
221
|
+
}
|
|
222
|
+
});
|
|
171
223
|
|
|
172
224
|
describe('product search with empty cache', () => {
|
|
173
225
|
const productSearchCacheKey = hash({
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const R = require('ramda');
|
|
4
4
|
const chance = require('chance').Chance();
|
|
5
|
+
const request = require('supertest');
|
|
5
6
|
const slugify = require('../../test/slugify');
|
|
6
7
|
|
|
7
8
|
const { env: { adminKey } } = process;
|
|
@@ -18,6 +19,8 @@ describe('user', () => {
|
|
|
18
19
|
};
|
|
19
20
|
let doApiGet; let doApiPost; let
|
|
20
21
|
doApiDelete;
|
|
22
|
+
let app;
|
|
23
|
+
let sqldb;
|
|
21
24
|
let appKey;
|
|
22
25
|
const userId = chance.guid();
|
|
23
26
|
let apiKey = chance.guid();
|
|
@@ -29,6 +32,8 @@ describe('user', () => {
|
|
|
29
32
|
let plugins;
|
|
30
33
|
beforeAll(async () => {
|
|
31
34
|
({
|
|
35
|
+
app,
|
|
36
|
+
sqldb,
|
|
32
37
|
doApiDelete,
|
|
33
38
|
doApiGet,
|
|
34
39
|
doApiPost,
|
|
@@ -162,6 +167,28 @@ describe('user', () => {
|
|
|
162
167
|
});
|
|
163
168
|
expect(returnValue.token).toEqual(token2);
|
|
164
169
|
});
|
|
170
|
+
it('should return a preflight response for token endpoint', async () => {
|
|
171
|
+
const response = await request(app)
|
|
172
|
+
.options(`/${appName}/${userId}/token`)
|
|
173
|
+
.set('Origin', 'https://tcoutlook.tourconnect.dev')
|
|
174
|
+
.set('Access-Control-Request-Method', 'GET');
|
|
175
|
+
expect([200, 204]).toContain(response.statusCode);
|
|
176
|
+
expect(response.headers['access-control-allow-origin']).toBeTruthy();
|
|
177
|
+
});
|
|
178
|
+
it('should keep returning 404 for a missing token hint', async () => {
|
|
179
|
+
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
180
|
+
const missingHint = chance.guid();
|
|
181
|
+
try {
|
|
182
|
+
const returnValue = await doApiGet({
|
|
183
|
+
url: `/${appName}/${userId}/${missingHint}/token`,
|
|
184
|
+
token: userKey,
|
|
185
|
+
expectStatusCode: 404,
|
|
186
|
+
});
|
|
187
|
+
expect(returnValue.message).toContain('User integration is not found');
|
|
188
|
+
} finally {
|
|
189
|
+
consoleErrorSpy.mockRestore();
|
|
190
|
+
}
|
|
191
|
+
});
|
|
165
192
|
it('should be able to create an app setting', async () => {
|
|
166
193
|
const returnValue = await doApiPost({
|
|
167
194
|
url: `/settings/${appName}/${userId}`,
|
|
@@ -211,4 +238,92 @@ describe('user', () => {
|
|
|
211
238
|
]),
|
|
212
239
|
);
|
|
213
240
|
});
|
|
241
|
+
it('should return 422 when stored app token is corrupted', async () => {
|
|
242
|
+
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
|
243
|
+
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
244
|
+
const [originalRows] = await sqldb.query(
|
|
245
|
+
'SELECT id, appKey FROM UserAppKeys WHERE integrationId = :integrationId AND userId = :userId',
|
|
246
|
+
{
|
|
247
|
+
replacements: {
|
|
248
|
+
integrationId: appName,
|
|
249
|
+
userId,
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
);
|
|
253
|
+
await sqldb.query(
|
|
254
|
+
'UPDATE UserAppKeys SET appKey = :appKey WHERE integrationId = :integrationId AND userId = :userId',
|
|
255
|
+
{
|
|
256
|
+
replacements: {
|
|
257
|
+
appKey: 'corrupted-token-without-delimiter',
|
|
258
|
+
integrationId: appName,
|
|
259
|
+
userId,
|
|
260
|
+
},
|
|
261
|
+
},
|
|
262
|
+
);
|
|
263
|
+
try {
|
|
264
|
+
const returnValue = await doApiGet({
|
|
265
|
+
url: `/${appName}/${userId}/token`,
|
|
266
|
+
token: userKey,
|
|
267
|
+
expectStatusCode: 422,
|
|
268
|
+
});
|
|
269
|
+
expect(returnValue.message).toBe('Stored integration token is invalid. Please re-save credentials.');
|
|
270
|
+
} finally {
|
|
271
|
+
await Promise.all(originalRows.map(currentRow => sqldb.query(
|
|
272
|
+
'UPDATE UserAppKeys SET appKey = :appKey WHERE id = :id',
|
|
273
|
+
{
|
|
274
|
+
replacements: {
|
|
275
|
+
id: currentRow.id,
|
|
276
|
+
appKey: currentRow.appKey,
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
)));
|
|
280
|
+
consoleWarnSpy.mockRestore();
|
|
281
|
+
consoleErrorSpy.mockRestore();
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
it('should return 422 when stored app token is corrupted for hinted endpoint', async () => {
|
|
285
|
+
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
|
286
|
+
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
287
|
+
const [originalRows] = await sqldb.query(
|
|
288
|
+
'SELECT id, appKey FROM UserAppKeys WHERE integrationId = :integrationId AND userId = :userId AND hint = :hint',
|
|
289
|
+
{
|
|
290
|
+
replacements: {
|
|
291
|
+
integrationId: appName,
|
|
292
|
+
userId,
|
|
293
|
+
hint: apiKey2.split('-')[1],
|
|
294
|
+
},
|
|
295
|
+
},
|
|
296
|
+
);
|
|
297
|
+
await sqldb.query(
|
|
298
|
+
'UPDATE UserAppKeys SET appKey = :appKey WHERE integrationId = :integrationId AND userId = :userId AND hint = :hint',
|
|
299
|
+
{
|
|
300
|
+
replacements: {
|
|
301
|
+
appKey: 'corrupted-token-without-delimiter',
|
|
302
|
+
integrationId: appName,
|
|
303
|
+
userId,
|
|
304
|
+
hint: apiKey2.split('-')[1],
|
|
305
|
+
},
|
|
306
|
+
},
|
|
307
|
+
);
|
|
308
|
+
try {
|
|
309
|
+
const returnValue = await doApiGet({
|
|
310
|
+
url: `/${appName}/${userId}/${apiKey2.split('-')[1]}/token`,
|
|
311
|
+
token: userKey,
|
|
312
|
+
expectStatusCode: 422,
|
|
313
|
+
});
|
|
314
|
+
expect(returnValue.message).toBe('Stored integration token is invalid. Please re-save credentials.');
|
|
315
|
+
} finally {
|
|
316
|
+
await Promise.all(originalRows.map(currentRow => sqldb.query(
|
|
317
|
+
'UPDATE UserAppKeys SET appKey = :appKey WHERE id = :id',
|
|
318
|
+
{
|
|
319
|
+
replacements: {
|
|
320
|
+
id: currentRow.id,
|
|
321
|
+
appKey: currentRow.appKey,
|
|
322
|
+
},
|
|
323
|
+
},
|
|
324
|
+
)));
|
|
325
|
+
consoleWarnSpy.mockRestore();
|
|
326
|
+
consoleErrorSpy.mockRestore();
|
|
327
|
+
}
|
|
328
|
+
});
|
|
214
329
|
});
|
package/controllers/app.js
CHANGED
|
@@ -488,20 +488,24 @@ const getAppToken = async (req, res, next) => {
|
|
|
488
488
|
hint,
|
|
489
489
|
},
|
|
490
490
|
} = req;
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
491
|
+
try {
|
|
492
|
+
// check if the user exists
|
|
493
|
+
const userRecord = await sqldb.User.findOne({ where: { userId } });
|
|
494
|
+
if (!userRecord) return next({ status: 404, message: 'User does not exists' });
|
|
495
|
+
const userAppKey = await sqldb.UserAppKey.findOne({
|
|
496
|
+
where: {
|
|
497
|
+
userId,
|
|
498
|
+
integrationId,
|
|
499
|
+
...(hint ? { hint } : {}),
|
|
500
|
+
},
|
|
501
|
+
});
|
|
502
|
+
if (!userAppKey) {
|
|
503
|
+
return next({ status: 404, message: `User integration is not found for ${integrationId}:${userId}:${hint}` });
|
|
504
|
+
}
|
|
505
|
+
return res.json({ token: await userAppKey.token });
|
|
506
|
+
} catch (err) {
|
|
507
|
+
return next(err);
|
|
503
508
|
}
|
|
504
|
-
return res.json({ token: await userAppKey.token });
|
|
505
509
|
};
|
|
506
510
|
|
|
507
511
|
const createCronjob = async (req, res, next) => {
|
package/index.js
CHANGED
|
@@ -48,6 +48,7 @@ const rebuild = fn => obj =>
|
|
|
48
48
|
Object.fromEntries(Object.entries(obj).flatMap(([k, v]) => fn(k, v)));
|
|
49
49
|
|
|
50
50
|
const isNumber = value => !Number.isNaN(Number(value));
|
|
51
|
+
const isCorruptedTokenError = err => R.pathOr(false, ['code'], err) === 'CORRUPTED_TOKEN';
|
|
51
52
|
|
|
52
53
|
const axiosSafeRequest = R.pick(['headers', 'method', 'url', 'data']);
|
|
53
54
|
const axiosSafeResponse = response => {
|
|
@@ -163,6 +164,7 @@ module.exports = async ({
|
|
|
163
164
|
if (apiDocs) {
|
|
164
165
|
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(allSchema));
|
|
165
166
|
}
|
|
167
|
+
app.options('*', middleware.CORS(), (_req, res) => res.sendStatus(204));
|
|
166
168
|
app.use(
|
|
167
169
|
middleware.metadata(),
|
|
168
170
|
middleware.CORS(),
|
|
@@ -373,6 +375,17 @@ module.exports = async ({
|
|
|
373
375
|
app.use(middleware.mock());
|
|
374
376
|
// global error Handling
|
|
375
377
|
app.use((err, req, res, next) => {
|
|
378
|
+
if (isCorruptedTokenError(err) && !process.env.JEST_WORKER_ID) {
|
|
379
|
+
console.warn('Invalid stored integration token', {
|
|
380
|
+
requestId: req.requestId,
|
|
381
|
+
integrationId: R.path(['context', 'integrationId'], err),
|
|
382
|
+
userId: R.path(['context', 'userId'], err),
|
|
383
|
+
hint: R.path(['context', 'hint'], err),
|
|
384
|
+
errorCode: R.path(['cause', 'code'], err),
|
|
385
|
+
errorName: R.path(['cause', 'name'], err),
|
|
386
|
+
message: R.path(['cause', 'message'], err),
|
|
387
|
+
});
|
|
388
|
+
}
|
|
376
389
|
if (process.env.CONSOLE_ERRORS || process.env.JEST_WORKER_ID) {
|
|
377
390
|
console.error(R.pathOr(err, ['response', 'data'], err));
|
|
378
391
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
const buildCorruptedTokenError = ({
|
|
2
|
+
integrationId,
|
|
3
|
+
userId,
|
|
4
|
+
hint,
|
|
5
|
+
cause,
|
|
6
|
+
}) => {
|
|
7
|
+
const error = new Error(
|
|
8
|
+
'Stored integration token is invalid. Please re-save credentials.',
|
|
9
|
+
);
|
|
10
|
+
error.name = 'CorruptedTokenError';
|
|
11
|
+
error.code = 'CORRUPTED_TOKEN';
|
|
12
|
+
error.status = 422;
|
|
13
|
+
error.context = {
|
|
14
|
+
integrationId,
|
|
15
|
+
userId,
|
|
16
|
+
hint,
|
|
17
|
+
};
|
|
18
|
+
error.cause = cause;
|
|
19
|
+
return error;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
module.exports = buildCorruptedTokenError;
|
package/models/userappkey.js
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
const jwt = require('jsonwebtoken');
|
|
2
1
|
const Sequelize = require('sequelize');
|
|
3
2
|
const R = require('ramda');
|
|
4
3
|
const { encrypt, decrypt } = require('../lib/security');
|
|
4
|
+
const buildCorruptedTokenError = require('../lib/corrupted-token-error');
|
|
5
5
|
const db = require('./db');
|
|
6
6
|
|
|
7
|
-
const { env: { dbCryptoKey } } = process;
|
|
8
7
|
const removeEmptyAttributes = obj => R.reject(R.anyPass([R.isEmpty, R.isNil]))(obj);
|
|
9
8
|
|
|
10
9
|
const UserAppKey = db.define('UserAppKey', {
|
|
@@ -50,13 +49,27 @@ const UserAppKey = db.define('UserAppKey', {
|
|
|
50
49
|
token: {
|
|
51
50
|
type: Sequelize.VIRTUAL,
|
|
52
51
|
async get() {
|
|
53
|
-
|
|
54
|
-
if (!
|
|
52
|
+
const encryptedAppKey = this.getDataValue('appKey');
|
|
53
|
+
if (!encryptedAppKey) return {};
|
|
54
|
+
|
|
55
|
+
let appKey;
|
|
56
|
+
try {
|
|
57
|
+
const decryptedAppKey = decrypt(encryptedAppKey);
|
|
58
|
+
if (!decryptedAppKey) return {};
|
|
59
|
+
appKey = JSON.parse(decryptedAppKey);
|
|
60
|
+
} catch (err) {
|
|
61
|
+
throw buildCorruptedTokenError({
|
|
62
|
+
integrationId: this.getDataValue('integrationId'),
|
|
63
|
+
userId: this.getDataValue('userId'),
|
|
64
|
+
hint: this.getDataValue('hint'),
|
|
65
|
+
cause: err,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
55
69
|
const sqldb = require('./index');
|
|
56
70
|
const userIntegrationSettings = await sqldb.UserIntegrationSettings.findOne({
|
|
57
71
|
where: { userId: this.getDataValue('userId'), integrationId: this.getDataValue('integrationId') },
|
|
58
72
|
});
|
|
59
|
-
const appKey = JSON.parse(decrypt(this.getDataValue('appKey')));
|
|
60
73
|
return {
|
|
61
74
|
...R.pathOr({}, ['settings'], userIntegrationSettings),
|
|
62
75
|
...removeEmptyAttributes(appKey),
|
package/package.json
CHANGED
package/test/plugin.js
CHANGED
|
@@ -718,12 +718,13 @@ class Plugin {
|
|
|
718
718
|
* @param {string} args.payload.QB - 'Q' for quote, 'B' for booking
|
|
719
719
|
* @param {string} args.payload.quoteName - Name of the booking/quote
|
|
720
720
|
* @param {string} [args.payload.quoteId] - identifier of the booking/quote, if one is provided, we are expecting the service line to be inserted to an existing booking/quote
|
|
721
|
+
* @param {boolean} [args.payload.shellOnly] - Optional flag to create/update booking header only and skip service-line insertion
|
|
721
722
|
* @param {string} [args.payload.lineId] - identifier of the service line in the booking/quote, if one is provided, we are expecting an existing service line to be updated
|
|
722
723
|
* @param {string} [args.payload.rateId] - Rate identifier, we are sending 'Default' if no rates are provided by the check availability call
|
|
723
|
-
* @param {string} args.payload.optionId - Option identifier of the service line
|
|
724
|
-
* @param {string} args.payload.startDate - Start date always in (YYYY-MM-DD) format
|
|
724
|
+
* @param {string} args.payload.optionId - Option identifier of the service line (required unless shellOnly is true)
|
|
725
|
+
* @param {string} args.payload.startDate - Start date always in (YYYY-MM-DD) format (required unless shellOnly is true)
|
|
725
726
|
* @param {string} [args.payload.reference] - Reference number from external system
|
|
726
|
-
* @param {Array<PaxConfig>} args.payload.paxConfigs - Passenger configurations
|
|
727
|
+
* @param {Array<PaxConfig>} args.payload.paxConfigs - Passenger configurations (required unless shellOnly is true)
|
|
727
728
|
* @param {Array<Object>} [args.payload.extras] - Additional extras
|
|
728
729
|
* @param {Extra} args.payload.extras.selectedExtra - Selected extra
|
|
729
730
|
* @param {number} args.payload.extras.quantity - quantity
|
|
@@ -267,8 +267,9 @@ No, we don't have a specific test scenarios, but we recommend you to cover the f
|
|
|
267
267
|
quoteName: String,
|
|
268
268
|
rateId: String, // Optional
|
|
269
269
|
quoteId: String, // Optional
|
|
270
|
-
|
|
271
|
-
|
|
270
|
+
shellOnly: Boolean, // Optional. If true, create/update quote header only and skip service insertion
|
|
271
|
+
optionId: String, // Required unless shellOnly is true
|
|
272
|
+
startDate: String, // YYYY-MM-DD, required unless shellOnly is true
|
|
272
273
|
reference: String, // Optional
|
|
273
274
|
paxConfigs: [{
|
|
274
275
|
roomType: String, // Optional
|
|
@@ -280,7 +281,7 @@ No, we don't have a specific test scenarios, but we recommend you to cover the f
|
|
|
280
281
|
dob: String, // Optional, YYYY-MM-DD
|
|
281
282
|
personId: String // Optional
|
|
282
283
|
}]
|
|
283
|
-
}],
|
|
284
|
+
}], // Required unless shellOnly is true
|
|
284
285
|
extras: [{ // Optional
|
|
285
286
|
selectedExtra: {
|
|
286
287
|
id: String
|