@app-connect/core 1.7.34 → 1.7.36
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/connector/mock.js +9 -4
- package/connector/proxy/engine.js +3 -2
- package/connector/proxy/index.js +2 -2
- package/docs/architecture.md +1 -0
- package/docs/handlers.md +1 -1
- package/docs/models.md +2 -0
- package/docs/routes.md +1 -1
- package/handlers/disposition.js +3 -2
- package/handlers/log.js +18 -6
- package/handlers/plugin.js +13 -6
- package/index.js +22 -11
- package/lib/callLogLookup.js +82 -9
- package/lib/migrateCallLogsSchema.js +128 -7
- package/models/callLogModel.js +6 -0
- package/package.json +1 -1
- package/releaseNotes.json +44 -0
- package/test/connector/developerPortal.test.js +166 -0
- package/test/connector/mock.test.js +131 -0
- package/test/connector/proxy/engine.test.js +85 -0
- package/test/connector/proxy/index.test.js +246 -0
- package/test/connector/proxy/sample.json +1 -0
- package/test/handlers/admin.test.js +344 -0
- package/test/handlers/appointment.test.js +260 -0
- package/test/handlers/auth.test.js +6 -2
- package/test/handlers/calldown.test.js +310 -0
- package/test/handlers/disposition.test.js +396 -0
- package/test/handlers/log.test.js +327 -3
- package/test/handlers/managedOAuth.test.js +262 -0
- package/test/handlers/plugin.test.js +305 -0
- package/test/handlers/user.test.js +381 -0
- package/test/index.test.js +166 -1
- package/test/lib/analytics.test.js +146 -0
- package/test/lib/authSession.test.js +173 -0
- package/test/lib/encode.test.js +59 -0
- package/test/lib/errorHandler.test.js +246 -0
- package/test/lib/generalErrorMessage.test.js +82 -0
- package/test/lib/s3ErrorLogReport.test.js +187 -0
- package/test/mcp/mcpHandlerMore.test.js +384 -0
- package/test/mcp/tools/appointmentTools.test.js +362 -0
- package/test/models/callDownListModel.test.js +125 -0
- package/test/models/dynamo/lockSchema.test.js +37 -0
- package/test/models/dynamo/noteCacheSchema.test.js +45 -0
- package/test/models/llmSessionModel.test.js +91 -0
- package/test/models/models.test.js +92 -0
- package/test/routes/calldownRoutes.test.js +224 -0
- package/test/routes/coreRouterBroadRoutes.test.js +855 -0
- package/test/routes/dispositionRoutes.test.js +192 -0
- package/test/routes/managedAuthRoutes.test.js +151 -0
- package/test/routes/pluginRoutes.test.js +262 -0
|
@@ -38,6 +38,7 @@ const { composeCallLog } = require('../../lib/callLogComposer');
|
|
|
38
38
|
const { NoteCache } = require('../../models/dynamo/noteCacheSchema');
|
|
39
39
|
const axios = require('axios');
|
|
40
40
|
const { sequelize } = require('../../models/sequelize');
|
|
41
|
+
const { getHashValue } = require('../../lib/util');
|
|
41
42
|
|
|
42
43
|
describe('Log Handler', () => {
|
|
43
44
|
beforeAll(async () => {
|
|
@@ -170,6 +171,82 @@ describe('Log Handler', () => {
|
|
|
170
171
|
expect(savedLog.thirdPartyLogId).toBe('new-log-102');
|
|
171
172
|
});
|
|
172
173
|
|
|
174
|
+
test('should block duplicate client log when SSCL already logged same hashed extension', async () => {
|
|
175
|
+
// Arrange
|
|
176
|
+
const hashedExtensionId = getHashValue('rc-ext-1', 'test-hash-key');
|
|
177
|
+
await CallLogModel.create({
|
|
178
|
+
id: 'tel-session-123',
|
|
179
|
+
sessionId: 'session-123',
|
|
180
|
+
extensionNumber: '',
|
|
181
|
+
hashedExtensionId,
|
|
182
|
+
platform: 'testCRM',
|
|
183
|
+
thirdPartyLogId: 'sscl-log',
|
|
184
|
+
userId: 'test-user-id'
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
const incomingData = {
|
|
188
|
+
...mockIncomingData,
|
|
189
|
+
extensionNumber: '101',
|
|
190
|
+
hashedExtensionId
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
// Act
|
|
194
|
+
const result = await logHandler.createCallLog({
|
|
195
|
+
platform: 'testCRM',
|
|
196
|
+
userId: 'test-user-id',
|
|
197
|
+
incomingData,
|
|
198
|
+
hashedAccountId: 'hashed-123',
|
|
199
|
+
isFromSSCL: false
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// Assert
|
|
203
|
+
expect(result.successful).toBe(false);
|
|
204
|
+
expect(result.returnMessage.message).toContain('Existing log for session');
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('should derive hashed extension id from SSCL raw rcExtensionId when saving', async () => {
|
|
208
|
+
// Arrange
|
|
209
|
+
process.env.HASH_KEY = 'test-hash-key';
|
|
210
|
+
await UserModel.create(mockUser);
|
|
211
|
+
|
|
212
|
+
const mockConnector = {
|
|
213
|
+
getAuthType: jest.fn().mockResolvedValue('apiKey'),
|
|
214
|
+
getBasicAuth: jest.fn().mockReturnValue('base64-encoded'),
|
|
215
|
+
getLogFormatType: jest.fn().mockReturnValue('text/plain'),
|
|
216
|
+
createCallLog: jest.fn().mockResolvedValue({
|
|
217
|
+
logId: 'new-sscl-log',
|
|
218
|
+
returnMessage: { message: 'Call logged', messageType: 'success', ttl: 2000 }
|
|
219
|
+
})
|
|
220
|
+
};
|
|
221
|
+
connectorRegistry.getConnector.mockReturnValue(mockConnector);
|
|
222
|
+
composeCallLog.mockReturnValue('Composed log details');
|
|
223
|
+
|
|
224
|
+
const incomingData = {
|
|
225
|
+
...mockIncomingData,
|
|
226
|
+
rcExtensionId: 'rc-ext-1'
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
// Act
|
|
230
|
+
const result = await logHandler.createCallLog({
|
|
231
|
+
platform: 'testCRM',
|
|
232
|
+
userId: 'test-user-id',
|
|
233
|
+
incomingData,
|
|
234
|
+
hashedAccountId: 'hashed-123',
|
|
235
|
+
isFromSSCL: true
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// Assert
|
|
239
|
+
expect(result.successful).toBe(true);
|
|
240
|
+
const savedLog = await CallLogModel.findOne({
|
|
241
|
+
where: {
|
|
242
|
+
sessionId: 'session-123',
|
|
243
|
+
hashedExtensionId: getHashValue('rc-ext-1', 'test-hash-key')
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
expect(savedLog).not.toBeNull();
|
|
247
|
+
expect(savedLog.thirdPartyLogId).toBe('new-sscl-log');
|
|
248
|
+
});
|
|
249
|
+
|
|
173
250
|
test('should return warning when user not found', async () => {
|
|
174
251
|
// Act
|
|
175
252
|
const result = await logHandler.createCallLog({
|
|
@@ -324,7 +401,7 @@ describe('Log Handler', () => {
|
|
|
324
401
|
expect(result.successful).toBe(true);
|
|
325
402
|
expect(axios.post).toHaveBeenCalledWith(
|
|
326
403
|
'https://plugins.example.com/plugin/testPlugin',
|
|
327
|
-
{ data: mockIncomingData, config: null },
|
|
404
|
+
{ data: mockIncomingData, config: null, hashedExtensionId: null },
|
|
328
405
|
{
|
|
329
406
|
headers: {
|
|
330
407
|
Authorization: 'Bearer plugin-jwt-token'
|
|
@@ -402,7 +479,8 @@ describe('Log Handler', () => {
|
|
|
402
479
|
data: mockIncomingData,
|
|
403
480
|
config: null,
|
|
404
481
|
asyncTaskId: cache.id,
|
|
405
|
-
callbackUrl: `https://app.example.com/plugin/async-callback/${cache.id}
|
|
482
|
+
callbackUrl: `https://app.example.com/plugin/async-callback/${cache.id}`,
|
|
483
|
+
hashedExtensionId: null
|
|
406
484
|
});
|
|
407
485
|
expect(pluginCall[2]).toEqual({
|
|
408
486
|
headers: {
|
|
@@ -418,6 +496,53 @@ describe('Log Handler', () => {
|
|
|
418
496
|
}
|
|
419
497
|
});
|
|
420
498
|
|
|
499
|
+
test('should mark async plugin task failed when token sync URL is missing', async () => {
|
|
500
|
+
await UserModel.create(mockUser);
|
|
501
|
+
await AccountDataModel.create({
|
|
502
|
+
rcAccountId: mockUser.rcAccountId,
|
|
503
|
+
platformName: 'asyncPlugin',
|
|
504
|
+
dataKey: 'pluginData',
|
|
505
|
+
data: {
|
|
506
|
+
name: 'plugin.async',
|
|
507
|
+
supportedLogTypes: ['call'],
|
|
508
|
+
isAsync: true,
|
|
509
|
+
endpointUrl: 'https://plugins.example.com/plugin/asyncPlugin',
|
|
510
|
+
jwtToken: 'plugin-jwt-token'
|
|
511
|
+
}
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
const mockConnector = {
|
|
515
|
+
getAuthType: jest.fn().mockResolvedValue('apiKey'),
|
|
516
|
+
getBasicAuth: jest.fn().mockReturnValue('base64-encoded'),
|
|
517
|
+
getLogFormatType: jest.fn().mockReturnValue('text/plain'),
|
|
518
|
+
createCallLog: jest.fn().mockResolvedValue({
|
|
519
|
+
logId: 'new-log-async-failed',
|
|
520
|
+
returnMessage: { message: 'Call logged', messageType: 'success', ttl: 2000 }
|
|
521
|
+
})
|
|
522
|
+
};
|
|
523
|
+
connectorRegistry.getConnector.mockReturnValue(mockConnector);
|
|
524
|
+
composeCallLog.mockReturnValue('Composed log details');
|
|
525
|
+
|
|
526
|
+
const result = await logHandler.createCallLog({
|
|
527
|
+
platform: 'testCRM',
|
|
528
|
+
userId: 'test-user-id',
|
|
529
|
+
incomingData: mockIncomingData,
|
|
530
|
+
hashedAccountId: 'hashed-123',
|
|
531
|
+
isFromSSCL: false
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
expect(result.successful).toBe(true);
|
|
535
|
+
const cache = await CacheModel.findOne({
|
|
536
|
+
where: {
|
|
537
|
+
cacheKey: 'asyncPluginTask-asyncPlugin'
|
|
538
|
+
}
|
|
539
|
+
});
|
|
540
|
+
expect(cache).not.toBeNull();
|
|
541
|
+
expect(cache.status).toBe('failed');
|
|
542
|
+
expect(cache.data.message).toBe('Plugin token sync URL is not set');
|
|
543
|
+
expect(axios.post).not.toHaveBeenCalled();
|
|
544
|
+
});
|
|
545
|
+
|
|
421
546
|
test('should successfully create call log with oauth auth', async () => {
|
|
422
547
|
// Arrange
|
|
423
548
|
const oauthUser = { ...mockUser };
|
|
@@ -616,6 +741,43 @@ describe('Log Handler', () => {
|
|
|
616
741
|
}]);
|
|
617
742
|
});
|
|
618
743
|
|
|
744
|
+
test('should match SSCL log by hashedExtensionId when extensionNumber differs', async () => {
|
|
745
|
+
// Arrange
|
|
746
|
+
await UserModel.create({
|
|
747
|
+
id: 'test-user-id',
|
|
748
|
+
platform: 'testCRM',
|
|
749
|
+
accessToken: 'test-token'
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
await CallLogModel.create({
|
|
753
|
+
id: 'call-1',
|
|
754
|
+
sessionId: 'session-1',
|
|
755
|
+
extensionNumber: '',
|
|
756
|
+
hashedExtensionId: 'hashed-ext-1',
|
|
757
|
+
platform: 'testCRM',
|
|
758
|
+
thirdPartyLogId: 'sscl-log',
|
|
759
|
+
userId: 'test-user-id'
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
// Act
|
|
763
|
+
const result = await logHandler.getCallLog({
|
|
764
|
+
userId: 'test-user-id',
|
|
765
|
+
sessionIds: 'session-1',
|
|
766
|
+
extensionNumber: '101',
|
|
767
|
+
hashedExtensionId: 'hashed-ext-1',
|
|
768
|
+
platform: 'testCRM',
|
|
769
|
+
requireDetails: false
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
// Assert
|
|
773
|
+
expect(result.successful).toBe(true);
|
|
774
|
+
expect(result.logs).toEqual([{
|
|
775
|
+
sessionId: 'session-1',
|
|
776
|
+
matched: true,
|
|
777
|
+
logId: 'sscl-log'
|
|
778
|
+
}]);
|
|
779
|
+
});
|
|
780
|
+
|
|
619
781
|
test('should keep session-only lookup backward compatible when extensionNumber is empty', async () => {
|
|
620
782
|
// Arrange
|
|
621
783
|
await UserModel.create({
|
|
@@ -1290,6 +1452,21 @@ describe('Log Handler', () => {
|
|
|
1290
1452
|
});
|
|
1291
1453
|
|
|
1292
1454
|
describe('handleAsyncPluginCallback', () => {
|
|
1455
|
+
test('should return not found when callback task cache is missing', async () => {
|
|
1456
|
+
const result = await logHandler.handleAsyncPluginCallback({
|
|
1457
|
+
taskId: 'missing-task',
|
|
1458
|
+
body: {
|
|
1459
|
+
successful: true,
|
|
1460
|
+
note: 'Callback note'
|
|
1461
|
+
}
|
|
1462
|
+
});
|
|
1463
|
+
|
|
1464
|
+
expect(result).toEqual({
|
|
1465
|
+
statusCode: 404,
|
|
1466
|
+
body: { successful: false, message: 'Async task not found' }
|
|
1467
|
+
});
|
|
1468
|
+
});
|
|
1469
|
+
|
|
1293
1470
|
test('should append callback note to call log and remove task cache on success', async () => {
|
|
1294
1471
|
await UserModel.create({
|
|
1295
1472
|
id: 'test-user-id',
|
|
@@ -1411,6 +1588,154 @@ describe('Log Handler', () => {
|
|
|
1411
1588
|
expect(cache.data.message).toBe('Plugin failed');
|
|
1412
1589
|
});
|
|
1413
1590
|
|
|
1591
|
+
test('should destroy expired task cache and return not found', async () => {
|
|
1592
|
+
await CacheModel.create({
|
|
1593
|
+
id: 'task-expired',
|
|
1594
|
+
status: 'pending',
|
|
1595
|
+
userId: 'test-user-id',
|
|
1596
|
+
cacheKey: 'asyncPluginTask-testPlugin',
|
|
1597
|
+
expiry: new Date(Date.now() - 1000),
|
|
1598
|
+
data: {
|
|
1599
|
+
pluginId: 'testPlugin',
|
|
1600
|
+
platform: 'testCRM',
|
|
1601
|
+
userId: 'test-user-id',
|
|
1602
|
+
sessionId: 'session-1'
|
|
1603
|
+
}
|
|
1604
|
+
});
|
|
1605
|
+
|
|
1606
|
+
const result = await logHandler.handleAsyncPluginCallback({
|
|
1607
|
+
taskId: 'task-expired',
|
|
1608
|
+
body: {
|
|
1609
|
+
successful: true,
|
|
1610
|
+
note: 'Late callback note'
|
|
1611
|
+
}
|
|
1612
|
+
});
|
|
1613
|
+
|
|
1614
|
+
expect(result).toEqual({
|
|
1615
|
+
statusCode: 404,
|
|
1616
|
+
body: { successful: false, message: 'Async task not found' }
|
|
1617
|
+
});
|
|
1618
|
+
expect(await CacheModel.findByPk('task-expired')).toBeNull();
|
|
1619
|
+
expect(connectorRegistry.getConnector).not.toHaveBeenCalled();
|
|
1620
|
+
});
|
|
1621
|
+
|
|
1622
|
+
test('should mark task failed when callback cannot find the original call log', async () => {
|
|
1623
|
+
await CacheModel.create({
|
|
1624
|
+
id: 'task-missing-log',
|
|
1625
|
+
status: 'pending',
|
|
1626
|
+
userId: 'test-user-id',
|
|
1627
|
+
cacheKey: 'asyncPluginTask-testPlugin',
|
|
1628
|
+
expiry: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
|
1629
|
+
data: {
|
|
1630
|
+
pluginId: 'testPlugin',
|
|
1631
|
+
platform: 'testCRM',
|
|
1632
|
+
userId: 'test-user-id',
|
|
1633
|
+
sessionId: 'missing-session',
|
|
1634
|
+
extensionNumber: '',
|
|
1635
|
+
incomingData: {
|
|
1636
|
+
logInfo: {
|
|
1637
|
+
sessionId: 'missing-session'
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
});
|
|
1642
|
+
|
|
1643
|
+
const result = await logHandler.handleAsyncPluginCallback({
|
|
1644
|
+
taskId: 'task-missing-log',
|
|
1645
|
+
body: {
|
|
1646
|
+
successful: true,
|
|
1647
|
+
note: 'Callback note'
|
|
1648
|
+
}
|
|
1649
|
+
});
|
|
1650
|
+
|
|
1651
|
+
expect(result).toEqual({
|
|
1652
|
+
statusCode: 500,
|
|
1653
|
+
body: { successful: false, message: 'Call log not found for async plugin task' }
|
|
1654
|
+
});
|
|
1655
|
+
const cache = await CacheModel.findByPk('task-missing-log');
|
|
1656
|
+
expect(cache.status).toBe('failed');
|
|
1657
|
+
expect(cache.data.message).toBe('Call log not found for async plugin task');
|
|
1658
|
+
expect(connectorRegistry.getConnector).not.toHaveBeenCalled();
|
|
1659
|
+
});
|
|
1660
|
+
|
|
1661
|
+
test('should mark task failed when callback note cannot be written to CRM', async () => {
|
|
1662
|
+
await UserModel.create({
|
|
1663
|
+
id: 'test-user-id',
|
|
1664
|
+
platform: 'testCRM',
|
|
1665
|
+
accessToken: 'test-token',
|
|
1666
|
+
platformAdditionalInfo: {}
|
|
1667
|
+
});
|
|
1668
|
+
await CallLogModel.create({
|
|
1669
|
+
id: 'call-1',
|
|
1670
|
+
sessionId: 'session-1',
|
|
1671
|
+
extensionNumber: '',
|
|
1672
|
+
platform: 'testCRM',
|
|
1673
|
+
thirdPartyLogId: 'log-1',
|
|
1674
|
+
userId: 'test-user-id',
|
|
1675
|
+
contactId: 'contact-1'
|
|
1676
|
+
});
|
|
1677
|
+
await CacheModel.create({
|
|
1678
|
+
id: 'task-update-failed',
|
|
1679
|
+
status: 'pending',
|
|
1680
|
+
userId: 'test-user-id',
|
|
1681
|
+
cacheKey: 'asyncPluginTask-testPlugin',
|
|
1682
|
+
expiry: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
|
1683
|
+
data: {
|
|
1684
|
+
pluginId: 'testPlugin',
|
|
1685
|
+
platform: 'testCRM',
|
|
1686
|
+
userId: 'test-user-id',
|
|
1687
|
+
sessionId: 'session-1',
|
|
1688
|
+
extensionNumber: '',
|
|
1689
|
+
incomingData: {
|
|
1690
|
+
logInfo: {
|
|
1691
|
+
sessionId: 'session-1',
|
|
1692
|
+
startTime: '2026-06-12T10:00:00.000Z',
|
|
1693
|
+
duration: 120,
|
|
1694
|
+
result: 'Completed',
|
|
1695
|
+
direction: 'Outbound',
|
|
1696
|
+
from: { phoneNumber: '+1234567890' },
|
|
1697
|
+
to: { phoneNumber: '+1987654321' }
|
|
1698
|
+
},
|
|
1699
|
+
note: 'Original note'
|
|
1700
|
+
},
|
|
1701
|
+
hashedAccountId: 'hashed-123',
|
|
1702
|
+
isFromSSCL: false
|
|
1703
|
+
}
|
|
1704
|
+
});
|
|
1705
|
+
const mockConnector = {
|
|
1706
|
+
getAuthType: jest.fn().mockResolvedValue('apiKey'),
|
|
1707
|
+
getBasicAuth: jest.fn().mockReturnValue('base64-encoded'),
|
|
1708
|
+
getLogFormatType: jest.fn().mockReturnValue('text/plain'),
|
|
1709
|
+
getCallLog: jest.fn().mockResolvedValue({
|
|
1710
|
+
callLogInfo: {
|
|
1711
|
+
fullBody: 'Existing body',
|
|
1712
|
+
note: 'Existing note',
|
|
1713
|
+
fullLogResponse: { id: 'log-1' }
|
|
1714
|
+
}
|
|
1715
|
+
}),
|
|
1716
|
+
updateCallLog: jest.fn().mockRejectedValue(new Error('CRM update failed'))
|
|
1717
|
+
};
|
|
1718
|
+
connectorRegistry.getConnector.mockReturnValue(mockConnector);
|
|
1719
|
+
composeCallLog.mockReturnValue('Updated composed log');
|
|
1720
|
+
|
|
1721
|
+
const result = await logHandler.handleAsyncPluginCallback({
|
|
1722
|
+
taskId: 'task-update-failed',
|
|
1723
|
+
body: {
|
|
1724
|
+
successful: true,
|
|
1725
|
+
note: 'Callback note'
|
|
1726
|
+
}
|
|
1727
|
+
});
|
|
1728
|
+
|
|
1729
|
+
expect(result).toEqual({
|
|
1730
|
+
statusCode: 500,
|
|
1731
|
+
body: { successful: false, message: 'CRM update failed' }
|
|
1732
|
+
});
|
|
1733
|
+
const cache = await CacheModel.findByPk('task-update-failed');
|
|
1734
|
+
expect(cache).not.toBeNull();
|
|
1735
|
+
expect(cache.status).toBe('failed');
|
|
1736
|
+
expect(cache.data.message).toBe('CRM update failed');
|
|
1737
|
+
expect(mockConnector.updateCallLog).toHaveBeenCalled();
|
|
1738
|
+
});
|
|
1414
1739
|
test('should reject callback without successful boolean', async () => {
|
|
1415
1740
|
const result = await logHandler.handleAsyncPluginCallback({
|
|
1416
1741
|
taskId: 'task-1',
|
|
@@ -1520,4 +1845,3 @@ describe('Log Handler', () => {
|
|
|
1520
1845
|
});
|
|
1521
1846
|
});
|
|
1522
1847
|
});
|
|
1523
|
-
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
jest.mock('../../models/sequelize', () => {
|
|
2
|
+
const { Sequelize } = require('sequelize');
|
|
3
|
+
return {
|
|
4
|
+
sequelize: new Sequelize({
|
|
5
|
+
dialect: 'sqlite',
|
|
6
|
+
storage: ':memory:',
|
|
7
|
+
logging: false
|
|
8
|
+
})
|
|
9
|
+
};
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const managedOAuth = require('../../handlers/managedOAuth');
|
|
13
|
+
const { AccountDataModel } = require('../../models/accountDataModel');
|
|
14
|
+
const { CacheModel } = require('../../models/cacheModel');
|
|
15
|
+
const { sequelize } = require('../../models/sequelize');
|
|
16
|
+
|
|
17
|
+
describe('Managed OAuth Handler', () => {
|
|
18
|
+
beforeAll(async () => {
|
|
19
|
+
process.env.APP_SERVER_SECRET_KEY = 'test-app-server-secret-key-123456';
|
|
20
|
+
await AccountDataModel.sync({ force: true });
|
|
21
|
+
await CacheModel.sync({ force: true });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
afterEach(async () => {
|
|
25
|
+
await AccountDataModel.destroy({ where: {} });
|
|
26
|
+
await CacheModel.destroy({ where: {} });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
afterAll(async () => {
|
|
30
|
+
await sequelize.close();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('returns empty state when account and platform are missing', async () => {
|
|
34
|
+
await expect(managedOAuth.getManagedOAuthState({
|
|
35
|
+
rcAccountId: '',
|
|
36
|
+
platform: '',
|
|
37
|
+
isAdmin: false
|
|
38
|
+
})).resolves.toEqual({
|
|
39
|
+
isAdmin: false,
|
|
40
|
+
hasAccountOAuth: false,
|
|
41
|
+
hasPendingOAuth: false
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
await expect(managedOAuth.clearPendingManagedOAuth({})).resolves.toBe(0);
|
|
45
|
+
await expect(managedOAuth.clearAccountManagedOAuth({ rcAccountId: 'acc-1' })).resolves.toBe(0);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('upserts pending OAuth values, filters empty fields, and exposes pending values to admin', async () => {
|
|
49
|
+
const firstRecord = await managedOAuth.upsertPendingManagedOAuth({
|
|
50
|
+
rcAccountId: 'acc-1',
|
|
51
|
+
values: {
|
|
52
|
+
clientId: 'client-id',
|
|
53
|
+
clientSecret: 'client-secret',
|
|
54
|
+
accessTokenUri: '',
|
|
55
|
+
authorizationUri: 'https://auth.example.com',
|
|
56
|
+
ignored: 'ignored'
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
expect(firstRecord.id).toBe('acc-1-managed-oauth-account');
|
|
61
|
+
|
|
62
|
+
const secondRecord = await managedOAuth.upsertPendingManagedOAuth({
|
|
63
|
+
rcAccountId: 'acc-1',
|
|
64
|
+
values: {
|
|
65
|
+
clientId: 'client-id-2',
|
|
66
|
+
redirectUri: 'https://app.example.com/callback',
|
|
67
|
+
scopes: ['read', 'write'],
|
|
68
|
+
hostname: 'crm.example.com'
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
expect(secondRecord.id).toBe(firstRecord.id);
|
|
73
|
+
|
|
74
|
+
const pendingValues = await managedOAuth.getPendingManagedOAuthValues({ rcAccountId: 'acc-1' });
|
|
75
|
+
expect(pendingValues).toEqual({
|
|
76
|
+
clientId: 'client-id-2',
|
|
77
|
+
redirectUri: 'https://app.example.com/callback',
|
|
78
|
+
scopes: ['read', 'write'],
|
|
79
|
+
hostname: 'crm.example.com'
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const state = await managedOAuth.getManagedOAuthState({
|
|
83
|
+
rcAccountId: 'acc-1',
|
|
84
|
+
platform: 'testCRM',
|
|
85
|
+
isAdmin: true
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
expect(state).toEqual({
|
|
89
|
+
isAdmin: true,
|
|
90
|
+
hasAccountOAuth: false,
|
|
91
|
+
hasPendingOAuth: true,
|
|
92
|
+
pendingValues
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('expired pending OAuth cache is destroyed and ignored', async () => {
|
|
97
|
+
const record = await managedOAuth.upsertPendingManagedOAuth({
|
|
98
|
+
rcAccountId: 'expired-acc',
|
|
99
|
+
values: {
|
|
100
|
+
clientId: 'expired-client'
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
await record.update({
|
|
104
|
+
expiry: new Date(Date.now() - 1000)
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const values = await managedOAuth.getPendingManagedOAuthValues({ rcAccountId: 'expired-acc' });
|
|
108
|
+
|
|
109
|
+
expect(values).toEqual({});
|
|
110
|
+
expect(await CacheModel.findByPk('expired-acc-managed-oauth-account')).toBeNull();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('migrates pending OAuth to account record and hides account client secret in state', async () => {
|
|
114
|
+
await managedOAuth.upsertPendingManagedOAuth({
|
|
115
|
+
rcAccountId: 'acc-2',
|
|
116
|
+
values: {
|
|
117
|
+
clientId: 'client-id',
|
|
118
|
+
clientSecret: 'client-secret',
|
|
119
|
+
accessTokenUri: 'https://token.example.com'
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
await expect(managedOAuth.migratePendingManagedOAuth({
|
|
124
|
+
rcAccountId: 'acc-2',
|
|
125
|
+
platform: 'testCRM'
|
|
126
|
+
})).resolves.toBe(true);
|
|
127
|
+
|
|
128
|
+
expect(await managedOAuth.getPendingManagedOAuthValues({ rcAccountId: 'acc-2' })).toEqual({});
|
|
129
|
+
|
|
130
|
+
const accountValues = await managedOAuth.getAccountManagedOAuthValues({
|
|
131
|
+
rcAccountId: 'acc-2',
|
|
132
|
+
platform: 'testCRM'
|
|
133
|
+
});
|
|
134
|
+
expect(accountValues).toEqual({
|
|
135
|
+
clientId: 'client-id',
|
|
136
|
+
clientSecret: 'client-secret',
|
|
137
|
+
accessTokenUri: 'https://token.example.com'
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const state = await managedOAuth.getManagedOAuthState({
|
|
141
|
+
rcAccountId: 'acc-2',
|
|
142
|
+
platform: 'testCRM',
|
|
143
|
+
isAdmin: true
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
expect(state).toEqual({
|
|
147
|
+
isAdmin: true,
|
|
148
|
+
hasAccountOAuth: true,
|
|
149
|
+
hasPendingOAuth: false,
|
|
150
|
+
oauthValues: {
|
|
151
|
+
clientId: 'client-id',
|
|
152
|
+
accessTokenUri: 'https://token.example.com'
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('migrates pending OAuth into an existing account record and resolves account source first', async () => {
|
|
158
|
+
await managedOAuth.upsertPendingManagedOAuth({
|
|
159
|
+
rcAccountId: 'acc-3',
|
|
160
|
+
values: {
|
|
161
|
+
clientId: 'pending-client'
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
await AccountDataModel.create({
|
|
165
|
+
rcAccountId: 'acc-3',
|
|
166
|
+
platformName: 'testCRM',
|
|
167
|
+
dataKey: managedOAuth.MANAGED_OAUTH_ACCOUNT_DATA_KEY,
|
|
168
|
+
data: {
|
|
169
|
+
fields: {
|
|
170
|
+
clientId: {
|
|
171
|
+
encrypted: false,
|
|
172
|
+
value: 'old-client'
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
await expect(managedOAuth.migratePendingManagedOAuth({
|
|
179
|
+
rcAccountId: 'acc-3',
|
|
180
|
+
platform: 'testCRM'
|
|
181
|
+
})).resolves.toBe(true);
|
|
182
|
+
|
|
183
|
+
await expect(managedOAuth.resolveManagedOAuthInfo({
|
|
184
|
+
rcAccountId: 'acc-3',
|
|
185
|
+
platform: 'testCRM'
|
|
186
|
+
})).resolves.toEqual({
|
|
187
|
+
source: 'account',
|
|
188
|
+
oauthInfo: {
|
|
189
|
+
clientId: 'pending-client'
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('resolveManagedOAuthInfo falls back to pending values or null source', async () => {
|
|
195
|
+
await managedOAuth.upsertPendingManagedOAuth({
|
|
196
|
+
rcAccountId: 'acc-4',
|
|
197
|
+
values: {
|
|
198
|
+
clientId: 'pending-client'
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
await expect(managedOAuth.resolveManagedOAuthInfo({
|
|
203
|
+
rcAccountId: 'acc-4',
|
|
204
|
+
platform: 'testCRM'
|
|
205
|
+
})).resolves.toEqual({
|
|
206
|
+
source: 'pending',
|
|
207
|
+
oauthInfo: {
|
|
208
|
+
clientId: 'pending-client'
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
await expect(managedOAuth.resolveManagedOAuthInfo({
|
|
213
|
+
rcAccountId: 'missing-acc',
|
|
214
|
+
platform: 'testCRM'
|
|
215
|
+
})).resolves.toEqual({
|
|
216
|
+
source: null,
|
|
217
|
+
oauthInfo: null
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test('clear and reset delete account and pending OAuth data', async () => {
|
|
222
|
+
await managedOAuth.upsertPendingManagedOAuth({
|
|
223
|
+
rcAccountId: 'acc-5',
|
|
224
|
+
values: {
|
|
225
|
+
clientId: 'pending-client'
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
await managedOAuth.migratePendingManagedOAuth({
|
|
229
|
+
rcAccountId: 'acc-5',
|
|
230
|
+
platform: 'testCRM'
|
|
231
|
+
});
|
|
232
|
+
await managedOAuth.upsertPendingManagedOAuth({
|
|
233
|
+
rcAccountId: 'acc-5',
|
|
234
|
+
values: {
|
|
235
|
+
clientId: 'pending-client-2'
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
const resetResult = await managedOAuth.resetManagedOAuth({
|
|
240
|
+
rcAccountId: 'acc-5',
|
|
241
|
+
platform: 'testCRM'
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
expect(resetResult).toEqual({
|
|
245
|
+
deletedAccountCount: 1,
|
|
246
|
+
deletedPendingCount: 1
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test('throws when upserting pending OAuth without account id and no-ops missing migrations', async () => {
|
|
251
|
+
await expect(managedOAuth.upsertPendingManagedOAuth({
|
|
252
|
+
values: {
|
|
253
|
+
clientId: 'client-id'
|
|
254
|
+
}
|
|
255
|
+
})).rejects.toThrow('rcAccountId is required');
|
|
256
|
+
|
|
257
|
+
await expect(managedOAuth.migratePendingManagedOAuth({
|
|
258
|
+
rcAccountId: 'no-pending',
|
|
259
|
+
platform: 'testCRM'
|
|
260
|
+
})).resolves.toBe(false);
|
|
261
|
+
});
|
|
262
|
+
});
|