@hubspot/ui-extensions-dev-server 1.1.5 → 1.1.7

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.
Files changed (133) hide show
  1. package/dist/lib/DevServerState.d.ts +1 -1
  2. package/dist/lib/ExtensionsWebSocket.js +26 -2
  3. package/dist/lib/__tests__/ExtensionsWebSocket.spec.js +42 -8
  4. package/dist/lib/__tests__/app-functions/context.spec.d.ts +1 -0
  5. package/dist/lib/__tests__/app-functions/context.spec.js +101 -0
  6. package/dist/lib/__tests__/app-functions/errorReporter.spec.d.ts +1 -0
  7. package/dist/lib/__tests__/app-functions/errorReporter.spec.js +102 -0
  8. package/dist/lib/__tests__/app-functions/executor_v20231.spec.d.ts +1 -0
  9. package/dist/lib/__tests__/app-functions/executor_v20231.spec.js +168 -0
  10. package/dist/lib/__tests__/app-functions/executor_v20232.spec.d.ts +1 -0
  11. package/dist/lib/__tests__/app-functions/executor_v20232.spec.js +190 -0
  12. package/dist/lib/__tests__/app-functions/fixtures/constants.d.ts +18 -0
  13. package/dist/lib/__tests__/app-functions/fixtures/constants.js +139 -0
  14. package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-async-fails.cjs +8 -0
  15. package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-async-fails.d.cts +1 -0
  16. package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-async-succeeds.cjs +8 -0
  17. package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-async-succeeds.d.cts +1 -0
  18. package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-callback-on-promise-rejected.cjs +8 -0
  19. package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-callback-on-promise-rejected.d.cts +1 -0
  20. package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-callback-on-promise-resolved.cjs +8 -0
  21. package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-callback-on-promise-resolved.d.cts +1 -0
  22. package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-does-not-export-main.cjs +4 -0
  23. package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-does-not-export-main.d.cts +1 -0
  24. package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-echos-input.cjs +8 -0
  25. package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-echos-input.d.cts +1 -0
  26. package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-logs.cjs +10 -0
  27. package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-logs.d.cts +1 -0
  28. package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-returns-function.cjs +4 -0
  29. package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-returns-function.d.cts +1 -0
  30. package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-returns-promise-rejected.cjs +7 -0
  31. package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-returns-promise-rejected.d.cts +1 -0
  32. package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-returns-promise-resolved.cjs +7 -0
  33. package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-returns-promise-resolved.d.cts +1 -0
  34. package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-returns-text.cjs +4 -0
  35. package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-returns-text.d.cts +1 -0
  36. package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-returns-undefined.cjs +4 -0
  37. package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-returns-undefined.d.cts +1 -0
  38. package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-throws-error.cjs +4 -0
  39. package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-throws-error.d.cts +1 -0
  40. package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-times-out.cjs +10 -0
  41. package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-times-out.d.cts +1 -0
  42. package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-undeclared.cjs +4 -0
  43. package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-undeclared.d.cts +1 -0
  44. package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-uses-secret.cjs +14 -0
  45. package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-uses-secret.d.cts +1 -0
  46. package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-async-fails.cjs +5 -0
  47. package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-async-fails.d.cts +1 -0
  48. package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-async-succeeds.cjs +5 -0
  49. package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-async-succeeds.d.cts +3 -0
  50. package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-calls-callback.cjs +4 -0
  51. package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-calls-callback.d.cts +1 -0
  52. package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-does-not-export-main.cjs +4 -0
  53. package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-does-not-export-main.d.cts +1 -0
  54. package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-echos-input.cjs +8 -0
  55. package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-echos-input.d.cts +5 -0
  56. package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-logs.cjs +10 -0
  57. package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-logs.d.cts +3 -0
  58. package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-returns-function.cjs +4 -0
  59. package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-returns-function.d.cts +1 -0
  60. package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-returns-implicitly.cjs +7 -0
  61. package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-returns-implicitly.d.cts +1 -0
  62. package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-returns-promise-rejected.cjs +4 -0
  63. package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-returns-promise-rejected.d.cts +1 -0
  64. package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-returns-promise-resolved.cjs +4 -0
  65. package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-returns-promise-resolved.d.cts +3 -0
  66. package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-returns-text.cjs +4 -0
  67. package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-returns-text.d.cts +1 -0
  68. package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-returns-undefined.cjs +4 -0
  69. package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-returns-undefined.d.cts +1 -0
  70. package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-throws-error.cjs +4 -0
  71. package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-throws-error.d.cts +1 -0
  72. package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-times-out.cjs +12 -0
  73. package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-times-out.d.cts +1 -0
  74. package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-undeclared.cjs +4 -0
  75. package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-undeclared.d.cts +1 -0
  76. package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-uses-secret.cjs +14 -0
  77. package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-uses-secret.d.cts +4 -0
  78. package/dist/lib/__tests__/app-functions/secrets.spec.d.ts +1 -0
  79. package/dist/lib/__tests__/app-functions/secrets.spec.js +278 -0
  80. package/dist/lib/__tests__/app-functions/services/AppProxyService.spec.d.ts +1 -0
  81. package/dist/lib/__tests__/app-functions/services/AppProxyService.spec.js +667 -0
  82. package/dist/lib/__tests__/app-functions/services/PrivateAppUserTokenManager.spec.d.ts +1 -0
  83. package/dist/lib/__tests__/app-functions/services/PrivateAppUserTokenManager.spec.js +243 -0
  84. package/dist/lib/__tests__/app-functions/services/services_v20231.spec.d.ts +1 -0
  85. package/dist/lib/__tests__/app-functions/services/services_v20231.spec.js +319 -0
  86. package/dist/lib/__tests__/app-functions/services/services_v20232.spec.d.ts +1 -0
  87. package/dist/lib/__tests__/app-functions/services/services_v20232.spec.js +302 -0
  88. package/dist/lib/__tests__/app-functions/setup.d.ts +1 -0
  89. package/dist/lib/__tests__/app-functions/setup.js +7 -0
  90. package/dist/lib/__tests__/app-functions/signing.spec.d.ts +1 -0
  91. package/dist/lib/__tests__/app-functions/signing.spec.js +460 -0
  92. package/dist/lib/__tests__/ast.spec.js +1 -1
  93. package/dist/lib/__tests__/server.spec.js +24 -2
  94. package/dist/lib/app-functions/api/privateAppUserToken.d.ts +16 -0
  95. package/dist/lib/app-functions/api/privateAppUserToken.js +28 -0
  96. package/dist/lib/app-functions/config.d.ts +4 -0
  97. package/dist/lib/app-functions/config.js +48 -0
  98. package/dist/lib/app-functions/constants.d.ts +26 -0
  99. package/dist/lib/app-functions/constants.js +63 -0
  100. package/dist/lib/app-functions/context.d.ts +3 -0
  101. package/dist/lib/app-functions/context.js +65 -0
  102. package/dist/lib/app-functions/errorReporter.d.ts +22 -0
  103. package/dist/lib/app-functions/errorReporter.js +42 -0
  104. package/dist/lib/app-functions/errors.d.ts +44 -0
  105. package/dist/lib/app-functions/errors.js +82 -0
  106. package/dist/lib/app-functions/executor.d.ts +3 -0
  107. package/dist/lib/app-functions/executor.js +131 -0
  108. package/dist/lib/app-functions/index.d.ts +4 -0
  109. package/dist/lib/app-functions/index.js +4 -0
  110. package/dist/lib/app-functions/secrets.d.ts +5 -0
  111. package/dist/lib/app-functions/secrets.js +55 -0
  112. package/dist/lib/app-functions/services/AppFunctionExecutionService.d.ts +2 -0
  113. package/dist/lib/app-functions/services/AppFunctionExecutionService.js +55 -0
  114. package/dist/lib/app-functions/services/AppProxyService.d.ts +5 -0
  115. package/dist/lib/app-functions/services/AppProxyService.js +196 -0
  116. package/dist/lib/app-functions/services/PrivateAppUserTokenManager.d.ts +22 -0
  117. package/dist/lib/app-functions/services/PrivateAppUserTokenManager.js +185 -0
  118. package/dist/lib/app-functions/services/constants.d.ts +4 -0
  119. package/dist/lib/app-functions/services/constants.js +4 -0
  120. package/dist/lib/app-functions/services/index.d.ts +3 -0
  121. package/dist/lib/app-functions/services/index.js +3 -0
  122. package/dist/lib/app-functions/services/messages.d.ts +14 -0
  123. package/dist/lib/app-functions/services/messages.js +36 -0
  124. package/dist/lib/app-functions/signing.d.ts +29 -0
  125. package/dist/lib/app-functions/signing.js +51 -0
  126. package/dist/lib/app-functions/types.d.ts +172 -0
  127. package/dist/lib/app-functions/types.js +6 -0
  128. package/dist/lib/app-functions/utils.d.ts +15 -0
  129. package/dist/lib/app-functions/utils.js +28 -0
  130. package/dist/lib/ast.js +2 -1
  131. package/dist/lib/server.js +15 -4
  132. package/dist/lib/types.d.ts +2 -2
  133. package/package.json +9 -6
@@ -0,0 +1,243 @@
1
+ import { vi, describe, beforeEach, afterEach, it, expect, } from 'vitest';
2
+ import { scopesOnAccessToken as __scopesOnAccessToken } from '@hubspot/local-dev-lib/personalAccessKey';
3
+ import { PrivateAppUserTokenManager } from "../../../app-functions/services/PrivateAppUserTokenManager.js";
4
+ import { fetchPrivateAppUserToken as __fetchPrivateAppUserToken, createPrivateAppUserToken as __createPrivateAppUserToken, updatePrivateAppUserToken as __updatePrivateAppUserToken, } from "../../../app-functions/api/privateAppUserToken.js";
5
+ import { HubSpotHttpError } from '@hubspot/local-dev-lib/models/HubSpotHttpError';
6
+ import { AxiosError, AxiosHeaders } from 'axios';
7
+ import { generateTokensEnableMessage, generateMissingScopesMessage, generateMissingScopesMoreContextMessage, } from "../../../app-functions/services/messages.js";
8
+ vi.mock('@hubspot/local-dev-lib/personalAccessKey');
9
+ vi.mock('../../../app-functions/api/privateAppUserToken.ts');
10
+ const scopesOnAccessToken = __scopesOnAccessToken;
11
+ const fetchPrivateAppUserToken = __fetchPrivateAppUserToken;
12
+ const createPrivateAppUserToken = __createPrivateAppUserToken;
13
+ const updatePrivateAppUserToken = __updatePrivateAppUserToken;
14
+ function mockAxiosResponse(data, status = 200, statusText = 'OK', headers = new AxiosHeaders(), config = {}) {
15
+ // Type cast needed due to AxiosHeaders type conflict between local axios@1.12.0 and @hubspot/local-dev-lib's axios dependency
16
+ // The runtime behavior is correct, just incompatible AxiosHeaders types between different axios versions
17
+ return {
18
+ data,
19
+ status,
20
+ statusText,
21
+ headers,
22
+ config: { ...config, headers },
23
+ };
24
+ }
25
+ describe('lib/PrivateAppUserTokenManager', () => {
26
+ const accountId = 123;
27
+ const pakScopesTokenEnabled = [
28
+ 'developer.private_app.temporary_token.read',
29
+ 'developer.private_app.temporary_token.write',
30
+ ];
31
+ const pakScopesTokenDisabled = ['developer.projects.read'];
32
+ let manager;
33
+ let logger;
34
+ beforeEach(() => {
35
+ logger = {
36
+ error: vi.fn(),
37
+ debug: vi.fn(),
38
+ info: vi.fn(),
39
+ warn: vi.fn(),
40
+ };
41
+ });
42
+ describe('init()', () => {
43
+ beforeEach(() => {
44
+ manager = new PrivateAppUserTokenManager(accountId, logger);
45
+ });
46
+ afterEach(() => {
47
+ manager.cleanup();
48
+ });
49
+ it('should enable PrivateAppUserTokenManager when personal access key has all scopes', async () => {
50
+ scopesOnAccessToken.mockResolvedValue(pakScopesTokenEnabled);
51
+ await manager.init();
52
+ expect(manager.isEnabled()).toBe(true);
53
+ // logs the proper messages info. there's no warn/devbug
54
+ expect(logger.info).toHaveBeenCalledWith(generateTokensEnableMessage(accountId));
55
+ expect(logger.debug).not.toHaveBeenCalled();
56
+ expect(logger.warn).not.toHaveBeenCalled();
57
+ });
58
+ it('should not enable PrivateAppUserTokenManager if the personal access key does not contain scopes', async () => {
59
+ scopesOnAccessToken.mockResolvedValue(pakScopesTokenDisabled);
60
+ await manager.init();
61
+ expect(manager.isEnabled()).toBe(false);
62
+ // logs the proper info and debug messages. there is no warn
63
+ expect(logger.info).toHaveBeenCalledWith(generateMissingScopesMessage(accountId));
64
+ expect(logger.debug).toHaveBeenCalledWith(generateMissingScopesMoreContextMessage(accountId));
65
+ expect(logger.warn).not.toHaveBeenCalled();
66
+ });
67
+ });
68
+ describe('getPrivateAppUserToken()', () => {
69
+ const systemTime = new Date(Date.UTC(2024, 5, 1, 0, 0, 0));
70
+ const token = {
71
+ userId: 111,
72
+ portalId: 123,
73
+ appId: 345,
74
+ scopeGroups: ['crm.objects.contacts.read'],
75
+ userTokenKey: 'pat-na1-u-FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF',
76
+ clientId: 'my-client-id',
77
+ expiresAt: '2024-06-01T01:00:00Z',
78
+ };
79
+ const tokenWithMoreScopes = {
80
+ ...token,
81
+ scopeGroups: ['crm.objects.contacts.read', 'crm.objects.contacts.write'],
82
+ };
83
+ const refreshedToken = { ...token, expiresAt: '2024-06-01T02:00:00Z' };
84
+ const expiredToken = { ...token, expiresAt: '2024-05-30T00:00:00Z' };
85
+ const headers = new AxiosHeaders();
86
+ const config = { url: 'https://api.google.com', headers };
87
+ const notFoundError = new HubSpotHttpError('Not Found', {
88
+ cause: new AxiosError('msg', 'code', config, undefined, {
89
+ status: 404,
90
+ data: { message: 'Not Found' },
91
+ statusText: 'Not Found',
92
+ config,
93
+ headers,
94
+ }),
95
+ });
96
+ beforeEach(() => {
97
+ vi.resetAllMocks();
98
+ scopesOnAccessToken.mockResolvedValue(pakScopesTokenEnabled);
99
+ vi.spyOn(global, 'setInterval');
100
+ vi.useFakeTimers();
101
+ vi.setSystemTime(systemTime);
102
+ manager = new PrivateAppUserTokenManager(accountId, logger);
103
+ manager.init();
104
+ });
105
+ afterEach(() => {
106
+ manager.cleanup();
107
+ vi.clearAllTimers();
108
+ });
109
+ it('should return undefined if class is not initilized', async () => {
110
+ manager = new PrivateAppUserTokenManager(accountId, logger); // set to new instance not initialized
111
+ const result = await manager.getPrivateAppUserToken(accountId);
112
+ expect(result).toBeUndefined();
113
+ });
114
+ it('should return undefined if user does not have the scopes', async () => {
115
+ scopesOnAccessToken.mockResolvedValue(pakScopesTokenDisabled);
116
+ manager = new PrivateAppUserTokenManager(accountId, logger); // set to new instance not initialized
117
+ await manager.init();
118
+ const result = await manager.getPrivateAppUserToken(accountId);
119
+ expect(result).toBeUndefined();
120
+ });
121
+ it('should fetch a existing valid Private App User Token', async () => {
122
+ fetchPrivateAppUserToken.mockResolvedValue(mockAxiosResponse(token));
123
+ const result = await manager.getPrivateAppUserToken(token.appId);
124
+ expect(result?.userTokenKey).toEqual(token.userTokenKey);
125
+ });
126
+ it('should used cached Private App User Token', async () => {
127
+ fetchPrivateAppUserToken.mockResolvedValue(mockAxiosResponse(token));
128
+ await manager.getPrivateAppUserToken(token.appId);
129
+ const result = await manager.getPrivateAppUserToken(token.appId);
130
+ expect(result?.userTokenKey).toEqual(token.userTokenKey);
131
+ expect(fetchPrivateAppUserToken).toHaveBeenCalledTimes(1);
132
+ });
133
+ it('should refresh existing expired Private App User Token', async () => {
134
+ fetchPrivateAppUserToken.mockResolvedValue(mockAxiosResponse(expiredToken));
135
+ updatePrivateAppUserToken.mockResolvedValue(mockAxiosResponse(token));
136
+ const result = await manager.getPrivateAppUserToken(token.appId);
137
+ expect(result?.userTokenKey).toEqual(token.userTokenKey);
138
+ expect(updatePrivateAppUserToken).toHaveBeenCalledTimes(1);
139
+ expect(updatePrivateAppUserToken).toHaveBeenCalledWith(expect.objectContaining({
140
+ expiresAt: '2024-06-01T00:15:00.000Z',
141
+ }));
142
+ });
143
+ it('should create a new Private App User Token if none exist', async () => {
144
+ fetchPrivateAppUserToken.mockRejectedValue(notFoundError);
145
+ createPrivateAppUserToken.mockResolvedValue(mockAxiosResponse(token, 200));
146
+ const result = await manager.getPrivateAppUserToken(token.appId);
147
+ expect(result?.userTokenKey).toEqual(token.userTokenKey);
148
+ expect(createPrivateAppUserToken).toHaveBeenCalledTimes(1);
149
+ expect(createPrivateAppUserToken).toHaveBeenCalledWith(expect.objectContaining({
150
+ expiresAt: '2024-06-01T00:15:00.000Z',
151
+ }));
152
+ });
153
+ it('should refresh cached token if it is within the threashold and user request a new token', async () => {
154
+ // token expires in 1 hour. try 56 minutes to test threadhold
155
+ const fiftyMinutesMilli = 56 * 60 * 1000;
156
+ fetchPrivateAppUserToken.mockResolvedValue(mockAxiosResponse(token));
157
+ updatePrivateAppUserToken.mockResolvedValue(mockAxiosResponse(refreshedToken));
158
+ const tokenResp = await manager.getPrivateAppUserToken(token.appId);
159
+ expect(tokenResp?.userTokenKey).toEqual(token.userTokenKey);
160
+ vi.advanceTimersByTime(fiftyMinutesMilli);
161
+ await manager.getPrivateAppUserToken(token.appId);
162
+ expect(updatePrivateAppUserToken).toHaveBeenCalledTimes(1);
163
+ });
164
+ it('should not refresh token is still valid', async () => {
165
+ // token expires in 1 hour. try 10 minutes to test threadhold
166
+ const tenMinutesMilli = 10 * 60 * 1000;
167
+ fetchPrivateAppUserToken.mockResolvedValue(mockAxiosResponse(token));
168
+ updatePrivateAppUserToken.mockResolvedValue(mockAxiosResponse(refreshedToken));
169
+ const tokenResp = await manager.getPrivateAppUserToken(token.appId);
170
+ expect(tokenResp?.userTokenKey).toEqual(token.userTokenKey);
171
+ vi.advanceTimersByTime(tenMinutesMilli);
172
+ await manager.getPrivateAppUserToken(token.appId);
173
+ expect(updatePrivateAppUserToken).not.toHaveBeenCalled();
174
+ });
175
+ it('should update token is missing scopes', async () => {
176
+ fetchPrivateAppUserToken.mockResolvedValue(mockAxiosResponse(token));
177
+ updatePrivateAppUserToken.mockResolvedValue(mockAxiosResponse(tokenWithMoreScopes));
178
+ await manager.getPrivateAppUserToken(token.appId);
179
+ const tokenResp = await manager.getPrivateAppUserToken(token.appId, {
180
+ scopeGroups: tokenWithMoreScopes.scopeGroups,
181
+ });
182
+ expect(tokenResp?.userTokenKey).toEqual(token.userTokenKey);
183
+ expect(fetchPrivateAppUserToken).toHaveBeenCalledTimes(2); // initial get, and then fetches again when scopes don't match in cached token
184
+ expect(updatePrivateAppUserToken).toHaveBeenCalledTimes(1);
185
+ });
186
+ it('should clear token refresh if cleanup is called', async () => {
187
+ fetchPrivateAppUserToken.mockResolvedValue(mockAxiosResponse(token));
188
+ await manager.getPrivateAppUserToken(token.appId);
189
+ manager.cleanup();
190
+ vi.advanceTimersByTime(60 * 60 * 1000);
191
+ expect(updatePrivateAppUserToken).not.toHaveBeenCalled();
192
+ });
193
+ });
194
+ describe('doesUserTokenContainAppTokenScopes', () => {
195
+ const tokenWithMatchingScopes = {
196
+ userId: 111,
197
+ portalId: 123,
198
+ appId: 345,
199
+ scopeGroups: ['crm.objects.contacts.read', 'crm.objects.contacts.write'],
200
+ userTokenKey: 'pat-na1-u-FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF',
201
+ clientId: 'my-client-id',
202
+ expiresAt: '2024-06-01T01:00:00Z',
203
+ privateAppTokenInfo: {
204
+ userId: 111,
205
+ portalId: 123,
206
+ appId: 345,
207
+ scopeGroups: [
208
+ 'crm.objects.contacts.read',
209
+ 'crm.objects.contacts.write',
210
+ ],
211
+ expiresAt: '2024-06-01T01:00:00Z',
212
+ },
213
+ };
214
+ it('should return true if the scopes match', () => {
215
+ expect(PrivateAppUserTokenManager.doesUserTokenContainAppTokenScopes(tokenWithMatchingScopes)).toBeTruthy();
216
+ });
217
+ it('should return false there is no PrivateAppToken', () => {
218
+ const localToken = {
219
+ ...tokenWithMatchingScopes,
220
+ privateAppTokenInfo: undefined,
221
+ };
222
+ expect(PrivateAppUserTokenManager.doesUserTokenContainAppTokenScopes(localToken)).toBeFalsy();
223
+ });
224
+ it('should return false if PrivateAppUserToken has extra scopes that are not in PrivateAppToken', () => {
225
+ const localToken = {
226
+ ...tokenWithMatchingScopes,
227
+ scopeGroups: [
228
+ 'crm.objects.contacts.read',
229
+ 'crm.objects.contacts.write',
230
+ 'crm.objects.deals.write',
231
+ ],
232
+ };
233
+ expect(PrivateAppUserTokenManager.doesUserTokenContainAppTokenScopes(localToken)).toBeTruthy();
234
+ });
235
+ it('should return true if PrivateAppUserToken is missing scopes', () => {
236
+ const localToken = {
237
+ ...tokenWithMatchingScopes,
238
+ scopeGroups: ['crm.objects.contacts.write'],
239
+ };
240
+ expect(PrivateAppUserTokenManager.doesUserTokenContainAppTokenScopes(localToken)).toBeFalsy();
241
+ });
242
+ });
243
+ });
@@ -0,0 +1,319 @@
1
+ import { describe, it, expect, vi, afterEach, beforeEach, } from 'vitest';
2
+ import httpMocks from 'node-mocks-http';
3
+ import { EventEmitter } from 'node:events';
4
+ import { AppFunctionExecutionService } from "../../../app-functions/services/index.js";
5
+ import { TEST_CONFIG_V20231 as TEST_CONFIG } from "../fixtures/constants.js";
6
+ import * as executor from "../../../app-functions/executor.js";
7
+ import { scopesOnAccessToken as __scopesOnAccessToken } from '@hubspot/local-dev-lib/personalAccessKey';
8
+ import { USER_TOKEN_READ, USER_TOKEN_WRITE, } from "../../../app-functions/services/constants.js";
9
+ import { PrivateAppUserTokenManager } from "../../../app-functions/services/PrivateAppUserTokenManager.js";
10
+ vi.mock('../../../app-functions/services/PrivateAppUserTokenManager.ts');
11
+ vi.mock('@hubspot/local-dev-lib/personalAccessKey');
12
+ const scopesOnAccessToken = __scopesOnAccessToken;
13
+ const callAppFunction = async (functionName, parameters) => {
14
+ const request = httpMocks.createRequest({
15
+ method: 'POST',
16
+ url: '/action/function/100',
17
+ params: {
18
+ id: 100,
19
+ },
20
+ body: {
21
+ serverlessFunction: functionName,
22
+ parameters,
23
+ },
24
+ });
25
+ const response = httpMocks.createResponse({
26
+ req: request,
27
+ eventEmitter: EventEmitter,
28
+ });
29
+ const handler = AppFunctionExecutionService(TEST_CONFIG);
30
+ // Hold response until the handler finishes writing to it. THis must
31
+ // be set up before calling the handler or it may miss the `end` event.
32
+ const responsePromised = new Promise((resolve) => {
33
+ response.on('end', () => {
34
+ resolve(response);
35
+ });
36
+ });
37
+ await handler(request, response);
38
+ return await responsePromised;
39
+ };
40
+ /**
41
+ * Validate the service for executing app functions
42
+ */
43
+ describe('app function dev server', () => {
44
+ const initialEnvJson = JSON.stringify(process.env);
45
+ beforeEach(() => {
46
+ scopesOnAccessToken.mockResolvedValue([USER_TOKEN_READ, USER_TOKEN_WRITE]);
47
+ });
48
+ afterEach(() => {
49
+ // restore process.env
50
+ process.env = JSON.parse(initialEnvJson);
51
+ vi.resetAllMocks();
52
+ vi.restoreAllMocks();
53
+ });
54
+ it('returns "200 OK" response if function execution succeeded', async () => {
55
+ const getTokenspy = vi.spyOn(PrivateAppUserTokenManager.prototype, 'getPrivateAppUserToken');
56
+ const response = await callAppFunction('returns-text');
57
+ // Validate response status and body
58
+ expect(response.statusCode).toEqual(200);
59
+ expect(response._getJSONData()).toEqual({
60
+ logId: 'n/a',
61
+ response: 'result',
62
+ });
63
+ // Validate log output
64
+ expect(TEST_CONFIG.logger.debug).toHaveBeenCalledWith(expect.stringContaining('App function "returns-text" execution succeeded'));
65
+ expect(TEST_CONFIG.logger.warn).toHaveBeenCalledTimes(0);
66
+ expect(TEST_CONFIG.logger.error).toHaveBeenCalledTimes(0);
67
+ // Validate process.env is put back to what it was before the call
68
+ expect(process.env).toEqual(JSON.parse(initialEnvJson));
69
+ // get the token
70
+ expect(getTokenspy).toHaveBeenCalledTimes(1);
71
+ });
72
+ it('returns "400 Bad Request" response if function execution failed', async () => {
73
+ const response = await callAppFunction('throws-error');
74
+ // Validate response status and body
75
+ expect(response.statusCode).toEqual(400);
76
+ const body = response._getJSONData();
77
+ expect(body).toHaveProperty('status', 'error');
78
+ expect(body).toHaveProperty('category', 'VALIDATION_ERROR');
79
+ const exception = body.errors?.[0]?.context?.exception?.[0];
80
+ expect(exception).toEqual('Error: Oops');
81
+ // Validate log output
82
+ expect(TEST_CONFIG.logger.warn).toHaveBeenCalledWith(expect.stringContaining('App function "throws-error" execution failed'));
83
+ expect(TEST_CONFIG.logger.error).toHaveBeenCalledWith('App function encountered an uncaught error.', expect.anything());
84
+ // Validate process.env is put back to what it was before the call
85
+ expect(process.env).toEqual(JSON.parse(initialEnvJson));
86
+ });
87
+ describe('handles asynchronous functions (promise chaining)', () => {
88
+ it('returns "200 OK" response if function returns a promise that resolves', async () => {
89
+ const response = await callAppFunction('returns-promise-resolved');
90
+ // Validate response status and body
91
+ expect(response.statusCode).toEqual(200);
92
+ expect(response._getJSONData()).toEqual({
93
+ logId: 'n/a',
94
+ response: { result: 'simulated' },
95
+ });
96
+ // Validate log output
97
+ expect(TEST_CONFIG.logger.debug).toHaveBeenCalledWith(expect.stringContaining('App function "returns-promise-resolved" execution succeeded'));
98
+ expect(TEST_CONFIG.logger.warn).toHaveBeenCalledTimes(0);
99
+ expect(TEST_CONFIG.logger.error).toHaveBeenCalledTimes(0);
100
+ // Validate process.env is put back to what it was before the call
101
+ expect(process.env).toEqual(JSON.parse(initialEnvJson));
102
+ });
103
+ it('returns "400 Bad Request" response if function returns a promise that rejects', async () => {
104
+ const response = await callAppFunction('returns-promise-rejected');
105
+ // Validate response status and body
106
+ expect(response.statusCode).toEqual(400);
107
+ const body = response._getJSONData();
108
+ expect(body).toHaveProperty('status', 'error');
109
+ expect(body).toHaveProperty('category', 'VALIDATION_ERROR');
110
+ const exception = body.errors?.[0]?.context?.exception?.[0];
111
+ expect(exception).toEqual('Error: fail');
112
+ // Validate log output
113
+ expect(TEST_CONFIG.logger.warn).toHaveBeenCalledWith(expect.stringContaining('App function "returns-promise-rejected" execution failed'));
114
+ expect(TEST_CONFIG.logger.error).toHaveBeenCalledWith('App function encountered an uncaught error.', expect.anything());
115
+ // Validate process.env is put back to what it was before the call
116
+ expect(process.env).toEqual(JSON.parse(initialEnvJson));
117
+ });
118
+ });
119
+ describe('handles asynchronous functions (async/await)', () => {
120
+ it('returns "200 OK" response if an async function execution succeeded', async () => {
121
+ const response = await callAppFunction('async-succeeds');
122
+ // Validate response status and body
123
+ expect(response.statusCode).toEqual(200);
124
+ expect(response._getJSONData()).toEqual({
125
+ logId: 'n/a',
126
+ response: { result: 'simulated' },
127
+ });
128
+ // Validate log output
129
+ expect(TEST_CONFIG.logger.debug).toHaveBeenCalledWith(expect.stringContaining('App function "async-succeeds" execution succeeded'));
130
+ expect(TEST_CONFIG.logger.warn).toHaveBeenCalledTimes(0);
131
+ expect(TEST_CONFIG.logger.error).toHaveBeenCalledTimes(0);
132
+ // Validate process.env is put back to what it was before the call
133
+ expect(process.env).toEqual(JSON.parse(initialEnvJson));
134
+ });
135
+ it('returns "400 Bad Request" response if an async function execution failed', async () => {
136
+ const response = await callAppFunction('async-fails');
137
+ // Validate response status and body
138
+ expect(response.statusCode).toEqual(400);
139
+ const body = response._getJSONData();
140
+ expect(body).toHaveProperty('status', 'error');
141
+ expect(body).toHaveProperty('category', 'VALIDATION_ERROR');
142
+ const exception = body.errors?.[0]?.context?.exception?.[0];
143
+ expect(exception).toEqual('Error: fail');
144
+ // Validate log output
145
+ expect(TEST_CONFIG.logger.warn).toHaveBeenCalledWith(expect.stringContaining('App function "async-fails" execution failed'));
146
+ expect(TEST_CONFIG.logger.error).toHaveBeenCalledWith('App function encountered an uncaught error.', expect.anything());
147
+ // Validate process.env is put back to what it was before the call
148
+ expect(process.env).toEqual(JSON.parse(initialEnvJson));
149
+ });
150
+ });
151
+ describe('handles functions with floating promises (only 2023.1)', () => {
152
+ it('returns "200 OK" response if function makes a promise that resolves and calls callback', async () => {
153
+ const response = await callAppFunction('callback-on-promise-resolved');
154
+ // Validate response status and body
155
+ expect(response.statusCode).toEqual(200);
156
+ expect(response._getJSONData()).toEqual({
157
+ logId: 'n/a',
158
+ response: { result: 'simulated' },
159
+ });
160
+ // Validate log output
161
+ expect(TEST_CONFIG.logger.debug).toHaveBeenCalledWith(expect.stringContaining('App function "callback-on-promise-resolved" execution succeeded'));
162
+ expect(TEST_CONFIG.logger.warn).toHaveBeenCalledTimes(0);
163
+ expect(TEST_CONFIG.logger.error).toHaveBeenCalledTimes(0);
164
+ // Validate process.env is put back to what it was before the call
165
+ expect(process.env).toEqual(JSON.parse(initialEnvJson));
166
+ });
167
+ it('returns "200 OK" response if function makes a promise that rejects and calls callback', async () => {
168
+ const response = await callAppFunction('callback-on-promise-rejected');
169
+ // Validate response status and body
170
+ expect(response.statusCode).toEqual(200);
171
+ expect(response._getJSONData()).toEqual({
172
+ logId: 'n/a',
173
+ response: { error: 'Error: fail' },
174
+ });
175
+ // Validate log output
176
+ expect(TEST_CONFIG.logger.debug).toHaveBeenCalledWith(expect.stringContaining('App function "callback-on-promise-rejected" execution succeeded'));
177
+ expect(TEST_CONFIG.logger.warn).toHaveBeenCalledTimes(0);
178
+ expect(TEST_CONFIG.logger.error).toHaveBeenCalledTimes(0);
179
+ // Validate process.env is put back to what it was before the call
180
+ expect(process.env).toEqual(JSON.parse(initialEnvJson));
181
+ });
182
+ });
183
+ it('returns "400 Bad Request" response if function not found', async () => {
184
+ const response = await callAppFunction('does-not-exist');
185
+ // Validate response status and body
186
+ expect(response.statusCode).toEqual(400);
187
+ const body = response._getJSONData();
188
+ expect(body).toHaveProperty('status', 'error');
189
+ expect(body).toHaveProperty('category', 'VALIDATION_ERROR');
190
+ const exception = body.errors?.[0]?.context?.exception?.[0];
191
+ expect(exception).toMatch(/doesn't exist in this project/);
192
+ // Validate log output
193
+ expect(TEST_CONFIG.logger.warn).toHaveBeenCalledWith(expect.stringContaining('App function "does-not-exist" execution failed'));
194
+ expect(TEST_CONFIG.logger.error).toHaveBeenCalledWith(expect.stringContaining('Could not find file'));
195
+ // Validate process.env is put back to what it was before the call
196
+ expect(process.env).toEqual(JSON.parse(initialEnvJson));
197
+ });
198
+ it('returns "400 Bad Request" response if function does not export main', async () => {
199
+ // TEST_CONFIG.functionTimeoutMs = 250
200
+ const response = await callAppFunction('does-not-export-main');
201
+ // Validate response status and body
202
+ expect(response.statusCode).toEqual(400);
203
+ const body = response._getJSONData();
204
+ expect(body).toHaveProperty('status', 'error');
205
+ expect(body).toHaveProperty('category', 'VALIDATION_ERROR');
206
+ const exception = body.errors?.[0]?.context?.exception?.[0];
207
+ expect(exception).toMatch(/customerPayload\.main is not a function/);
208
+ // Validate log output
209
+ expect(TEST_CONFIG.logger.warn).toHaveBeenCalledWith(expect.stringContaining('App function "does-not-export-main" execution failed'));
210
+ expect(TEST_CONFIG.logger.error).toHaveBeenCalledWith(expect.stringContaining('Could not find "main" export in'));
211
+ // Validate process.env is put back to what it was before the call
212
+ expect(process.env).toEqual(JSON.parse(initialEnvJson));
213
+ });
214
+ it('returns "400 Bad Request" response if function returns invalid json', async () => {
215
+ // TEST_CONFIG.functionTimeoutMs = 250
216
+ const response = await callAppFunction('returns-function');
217
+ // Validate response status and body
218
+ expect(response.statusCode).toEqual(400);
219
+ const body = response._getJSONData();
220
+ expect(body).toHaveProperty('status', 'error');
221
+ expect(body).toHaveProperty('category', 'VALIDATION_ERROR');
222
+ const exception = body.errors?.[0]?.context?.exception?.[0];
223
+ expect(exception).toMatch(/Wrong arguments/);
224
+ // Validate log output
225
+ expect(TEST_CONFIG.logger.warn).toHaveBeenCalledWith(expect.stringContaining('App function "returns-function" execution failed'));
226
+ expect(TEST_CONFIG.logger.error).toHaveBeenCalledWith('App function reponse is not valid JSON.');
227
+ // Validate process.env is put back to what it was before the call
228
+ expect(process.env).toEqual(JSON.parse(initialEnvJson));
229
+ });
230
+ it('returns "200 OK" response if function returns before timeout', async () => {
231
+ // TEST_CONFIG.functionTimeoutMs = 250
232
+ const response = await callAppFunction('times-out', { delayMs: 200 });
233
+ const variance = 5; // milliseconds
234
+ // Validate response status and body
235
+ expect(response.statusCode).toEqual(200);
236
+ const body = response._getJSONData();
237
+ expect(body).toEqual({
238
+ logId: 'n/a',
239
+ response: { status: 'success', elapsedMs: expect.anything() },
240
+ });
241
+ expect(body.response.elapsedMs).toBeGreaterThanOrEqual(200 - variance);
242
+ expect(body.response.elapsedMs).toBeLessThanOrEqual(200 + variance);
243
+ // Validate log output
244
+ expect(TEST_CONFIG.logger.debug).toHaveBeenCalledWith(expect.stringContaining('App function "times-out" execution succeeded'));
245
+ expect(TEST_CONFIG.logger.warn).toHaveBeenCalledTimes(0);
246
+ expect(TEST_CONFIG.logger.error).toHaveBeenCalledTimes(0);
247
+ // Validate process.env is put back to what it was before the call
248
+ expect(process.env).toEqual(JSON.parse(initialEnvJson));
249
+ });
250
+ it('returns "400 Bad Request" response if function returns after timeout', async () => {
251
+ // TEST_CONFIG.functionTimeoutMs = 250
252
+ const response = await callAppFunction('times-out', { delayMs: 300 });
253
+ // Validate response status and body
254
+ expect(response.statusCode).toEqual(400);
255
+ const body = response._getJSONData();
256
+ expect(body).toHaveProperty('status', 'error');
257
+ expect(body).toHaveProperty('category', 'VALIDATION_ERROR');
258
+ const exception = body.errors?.[0]?.context?.exception?.[0];
259
+ expect(exception).toMatch(/Task timed out after/);
260
+ // Validate log output
261
+ expect(TEST_CONFIG.logger.warn).toHaveBeenCalledWith(expect.stringContaining('App function "times-out" execution failed'));
262
+ expect(TEST_CONFIG.logger.error).toHaveBeenCalledWith('App function failed to callback within 0.25 second.');
263
+ // Validate process.env is put back to what it was before the call
264
+ expect(process.env).toEqual(JSON.parse(initialEnvJson));
265
+ });
266
+ it('returns "500 Internal Server Error" response if something went wrong', async () => {
267
+ const error = new Error('Something went terribly wrong!');
268
+ vi.spyOn(executor, 'executeFunction').mockImplementation(() => {
269
+ throw error;
270
+ });
271
+ // Act
272
+ const response = await callAppFunction('returns-text');
273
+ // Validate response status and body
274
+ expect(response.statusCode).toEqual(500);
275
+ const body = response._getJSONData();
276
+ expect(body).toHaveProperty('status', 'error');
277
+ expect(body).not.toHaveProperty('category', 'INTERNAL_ERROR');
278
+ expect(body).toHaveProperty('message', 'internal error');
279
+ // Validate log output
280
+ expect(TEST_CONFIG.logger.warn).toHaveBeenCalledWith(expect.stringContaining('App function "returns-text" execution failed due to server internal error'));
281
+ expect(TEST_CONFIG.logger.error).toHaveBeenCalledWith(error);
282
+ // Validate process.env is put back to what it was before the call
283
+ expect(process.env).toEqual(JSON.parse(initialEnvJson));
284
+ });
285
+ it('logs to console from inside the function', async () => {
286
+ const log = vi.spyOn(console, 'log').mockImplementation(() => { });
287
+ const debug = vi.spyOn(console, 'debug').mockImplementation(() => { });
288
+ const info = vi.spyOn(console, 'info').mockImplementation(() => { });
289
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => { });
290
+ const error = vi.spyOn(console, 'error').mockImplementation(() => { });
291
+ // Act
292
+ const response = await callAppFunction('logs');
293
+ // Validate response status and body
294
+ expect(response.statusCode).toEqual(200);
295
+ expect(response._getJSONData()).toEqual({
296
+ logId: 'n/a',
297
+ response: { status: 'success' },
298
+ });
299
+ // Validate function logging
300
+ expect(log).toHaveBeenCalledTimes(1);
301
+ expect(log).toHaveBeenCalledWith('log line');
302
+ expect(debug).toHaveBeenCalledTimes(1);
303
+ expect(debug).toHaveBeenCalledWith('debug line');
304
+ expect(info).toHaveBeenCalledTimes(2);
305
+ expect(info).toHaveBeenNthCalledWith(1, 'info line');
306
+ expect(info).toHaveBeenNthCalledWith(2, 'print data:', [1, 2]);
307
+ expect(warn).toHaveBeenCalledTimes(1);
308
+ expect(warn).toHaveBeenCalledWith('warn line');
309
+ expect(error).toHaveBeenCalledTimes(1);
310
+ expect(error).toHaveBeenCalledWith('error line');
311
+ // Validate service logging, which is separate from function logging
312
+ expect(TEST_CONFIG.logger.debug).toHaveBeenCalledTimes(1);
313
+ expect(TEST_CONFIG.logger.debug).toHaveBeenNthCalledWith(1, expect.stringContaining('App function "logs" execution succeeded'));
314
+ expect(TEST_CONFIG.logger.warn).toHaveBeenCalledTimes(0);
315
+ expect(TEST_CONFIG.logger.error).toHaveBeenCalledTimes(0);
316
+ // Validate process.env is put back to what it was before the call
317
+ expect(process.env).toEqual(JSON.parse(initialEnvJson));
318
+ });
319
+ });