@shipfox/api-integration-jira 11.0.0 → 12.1.0
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/.turbo/turbo-build.log +17 -2
- package/CHANGELOG.md +36 -0
- package/README.md +58 -2
- package/dist/api/client.d.ts +34 -0
- package/dist/api/client.d.ts.map +1 -1
- package/dist/api/client.js +131 -0
- package/dist/api/client.js.map +1 -1
- package/dist/config.d.ts +0 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +1 -4
- package/dist/config.js.map +1 -1
- package/dist/core/agent-tools-provider.d.ts +26 -0
- package/dist/core/agent-tools-provider.d.ts.map +1 -0
- package/dist/core/agent-tools-provider.js +130 -0
- package/dist/core/agent-tools-provider.js.map +1 -0
- package/dist/core/agent-tools.d.ts +16 -0
- package/dist/core/agent-tools.d.ts.map +1 -0
- package/dist/core/agent-tools.js +441 -0
- package/dist/core/agent-tools.js.map +1 -0
- package/dist/core/install.d.ts +22 -0
- package/dist/core/install.d.ts.map +1 -1
- package/dist/core/install.js +68 -20
- package/dist/core/install.js.map +1 -1
- package/dist/core/tokens.d.ts +2 -0
- package/dist/core/tokens.d.ts.map +1 -1
- package/dist/core/tokens.js +129 -37
- package/dist/core/tokens.js.map +1 -1
- package/dist/core/webhook-processor.d.ts +15 -0
- package/dist/core/webhook-processor.d.ts.map +1 -0
- package/dist/core/webhook-processor.js +137 -0
- package/dist/core/webhook-processor.js.map +1 -0
- package/dist/core/webhook-registration.d.ts +26 -0
- package/dist/core/webhook-registration.d.ts.map +1 -0
- package/dist/core/webhook-registration.js +165 -0
- package/dist/core/webhook-registration.js.map +1 -0
- package/dist/core/webhook.d.ts +18 -0
- package/dist/core/webhook.d.ts.map +1 -0
- package/dist/core/webhook.js +38 -0
- package/dist/core/webhook.js.map +1 -0
- package/dist/db/db.d.ts +68 -0
- package/dist/db/db.d.ts.map +1 -1
- package/dist/db/installations.d.ts +33 -6
- package/dist/db/installations.d.ts.map +1 -1
- package/dist/db/installations.js +79 -7
- package/dist/db/installations.js.map +1 -1
- package/dist/db/schema/installations.d.ts +34 -0
- package/dist/db/schema/installations.d.ts.map +1 -1
- package/dist/db/schema/installations.js +12 -2
- package/dist/db/schema/installations.js.map +1 -1
- package/dist/index.d.ts +34 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +76 -8
- package/dist/index.js.map +1 -1
- package/dist/presentation/routes/install.d.ts +22 -0
- package/dist/presentation/routes/install.d.ts.map +1 -1
- package/dist/presentation/routes/install.js.map +1 -1
- package/dist/presentation/routes/webhooks.d.ts +8 -0
- package/dist/presentation/routes/webhooks.d.ts.map +1 -0
- package/dist/presentation/routes/webhooks.js +108 -0
- package/dist/presentation/routes/webhooks.js.map +1 -0
- package/dist/temporal/activities/index.d.ts +2 -0
- package/dist/temporal/activities/index.d.ts.map +1 -0
- package/dist/temporal/activities/index.js +3 -0
- package/dist/temporal/activities/index.js.map +1 -0
- package/dist/temporal/activities/refresh-jira-tokens.d.ts +20 -0
- package/dist/temporal/activities/refresh-jira-tokens.d.ts.map +1 -0
- package/dist/temporal/activities/refresh-jira-tokens.js +58 -0
- package/dist/temporal/activities/refresh-jira-tokens.js.map +1 -0
- package/dist/temporal/constants.d.ts +6 -0
- package/dist/temporal/constants.d.ts.map +1 -0
- package/dist/temporal/constants.js +8 -0
- package/dist/temporal/constants.js.map +1 -0
- package/dist/temporal/worker.d.ts +9 -0
- package/dist/temporal/worker.d.ts.map +1 -0
- package/dist/temporal/worker.js +26 -0
- package/dist/temporal/worker.js.map +1 -0
- package/dist/temporal/workflows/index.bundle.js +25225 -0
- package/dist/temporal/workflows/index.bundle.meta.json +1 -0
- package/dist/temporal/workflows/index.d.ts +2 -0
- package/dist/temporal/workflows/index.d.ts.map +1 -0
- package/dist/temporal/workflows/index.js +3 -0
- package/dist/temporal/workflows/index.js.map +1 -0
- package/dist/temporal/workflows/refresh-jira-tokens-cron.d.ts +2 -0
- package/dist/temporal/workflows/refresh-jira-tokens-cron.d.ts.map +1 -0
- package/dist/temporal/workflows/refresh-jira-tokens-cron.js +20 -0
- package/dist/temporal/workflows/refresh-jira-tokens-cron.js.map +1 -0
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/drizzle/0001_jira-token-refresh-state.sql +12 -0
- package/drizzle/meta/0001_snapshot.json +245 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +17 -8
- package/src/api/client.test.ts +236 -9
- package/src/api/client.ts +192 -0
- package/src/config.test.ts +0 -1
- package/src/config.ts +1 -4
- package/src/core/agent-tools-provider.test.ts +299 -0
- package/src/core/agent-tools-provider.ts +175 -0
- package/src/core/agent-tools.ts +450 -0
- package/src/core/install.test.ts +179 -0
- package/src/core/install.ts +85 -19
- package/src/core/tokens-refresh.test.ts +223 -4
- package/src/core/tokens.test.ts +1 -1
- package/src/core/tokens.ts +153 -33
- package/src/core/webhook-processor.test.ts +418 -0
- package/src/core/webhook-processor.ts +166 -0
- package/src/core/webhook-registration.test.ts +375 -0
- package/src/core/webhook-registration.ts +216 -0
- package/src/core/webhook.ts +69 -0
- package/src/db/installations.test.ts +148 -2
- package/src/db/installations.ts +133 -7
- package/src/db/schema/installations.ts +21 -1
- package/src/index.test.ts +42 -0
- package/src/index.ts +148 -5
- package/src/presentation/routes/install.ts +12 -0
- package/src/presentation/routes/webhooks.test.ts +132 -0
- package/src/presentation/routes/webhooks.ts +115 -0
- package/src/temporal/activities/index.ts +5 -0
- package/src/temporal/activities/refresh-jira-tokens.test.ts +156 -0
- package/src/temporal/activities/refresh-jira-tokens.ts +88 -0
- package/src/temporal/constants.ts +8 -0
- package/src/temporal/worker.ts +39 -0
- package/src/temporal/workflows/index.ts +1 -0
- package/src/temporal/workflows/refresh-jira-tokens-cron.ts +19 -0
- package/test/env.ts +0 -1
- package/test/factories/jira-installation.ts +4 -0
- package/tsconfig.build.tsbuildinfo +1 -1
package/src/core/tokens.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type {IntegrationConnectionLifecycleStatus} from '@shipfox/api-integration-spi';
|
|
2
|
+
import {logger} from '@shipfox/node-opentelemetry';
|
|
1
3
|
import {createJiraApiClient, type JiraApiClient} from '#api/client.js';
|
|
2
4
|
import {
|
|
3
5
|
JiraAccessTokenMissingError,
|
|
@@ -9,15 +11,16 @@ import {
|
|
|
9
11
|
getJiraInstallationByConnectionId,
|
|
10
12
|
updateJiraInstallationTokenExpiry,
|
|
11
13
|
withJiraRefreshLock,
|
|
14
|
+
withJiraRefreshLockAndWait,
|
|
12
15
|
} from '#db/installations.js';
|
|
13
16
|
|
|
14
17
|
const ACCESS_TOKEN_KEY = 'ACCESS_TOKEN';
|
|
15
18
|
const REFRESH_TOKEN_KEY = 'REFRESH_TOKEN';
|
|
16
19
|
const TOKEN_REFRESH_MARGIN_MS = 5 * 60 * 1000;
|
|
17
|
-
const tokenRefreshes = new Map<string, Promise<string>>();
|
|
18
20
|
|
|
19
21
|
export interface JiraConnectionResolverResult {
|
|
20
22
|
workspaceId: string;
|
|
23
|
+
lifecycleStatus: IntegrationConnectionLifecycleStatus;
|
|
21
24
|
}
|
|
22
25
|
|
|
23
26
|
export interface JiraSecretsStore {
|
|
@@ -60,10 +63,44 @@ export function jiraSecretsNamespace(connectionId: string): string {
|
|
|
60
63
|
|
|
61
64
|
export function createJiraTokenStore(params: CreateJiraTokenStoreParams): JiraTokenStore {
|
|
62
65
|
const client = params.client ?? createJiraApiClient();
|
|
63
|
-
|
|
66
|
+
const tokenRefreshes = new Map<string, Promise<string>>();
|
|
67
|
+
const refreshStateUnknownConnections = new Set<string>();
|
|
68
|
+
const credentialGenerations = new Map<string, number>();
|
|
69
|
+
const credentialWriteQueues = new Map<string, Promise<void>>();
|
|
70
|
+
const currentCredentialGeneration = (connectionId: string): number =>
|
|
71
|
+
credentialGenerations.get(connectionId) ?? 0;
|
|
72
|
+
const advanceCredentialGeneration = (connectionId: string): number => {
|
|
73
|
+
const generation = currentCredentialGeneration(connectionId) + 1;
|
|
74
|
+
credentialGenerations.set(connectionId, generation);
|
|
75
|
+
return generation;
|
|
76
|
+
};
|
|
77
|
+
function withCredentialWrite<T>(connectionId: string, operation: () => Promise<T>): Promise<T> {
|
|
78
|
+
const previous = credentialWriteQueues.get(connectionId) ?? Promise.resolve();
|
|
79
|
+
const operationResult = previous.then(operation);
|
|
80
|
+
const completion = operationResult.then(
|
|
81
|
+
() => undefined,
|
|
82
|
+
() => undefined,
|
|
83
|
+
);
|
|
84
|
+
credentialWriteQueues.set(connectionId, completion);
|
|
85
|
+
return operationResult.finally(() => {
|
|
86
|
+
if (credentialWriteQueues.get(connectionId) === completion) {
|
|
87
|
+
credentialWriteQueues.delete(connectionId);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
async function waitForCredentialWrites(connectionId: string): Promise<void> {
|
|
92
|
+
await credentialWriteQueues.get(connectionId);
|
|
93
|
+
}
|
|
94
|
+
async function resolveConnection(connectionId: string): Promise<JiraConnectionResolverResult> {
|
|
64
95
|
const connection = await params.resolveConnection(connectionId);
|
|
65
96
|
if (!connection) throw new JiraConnectionNotFoundError(connectionId);
|
|
66
|
-
return connection
|
|
97
|
+
return connection;
|
|
98
|
+
}
|
|
99
|
+
async function resolveWorkspaceId(connectionId: string): Promise<string> {
|
|
100
|
+
return (await resolveConnection(connectionId)).workspaceId;
|
|
101
|
+
}
|
|
102
|
+
function clearTokenRefresh(connectionId: string, refresh: Promise<string>): void {
|
|
103
|
+
if (tokenRefreshes.get(connectionId) === refresh) tokenRefreshes.delete(connectionId);
|
|
67
104
|
}
|
|
68
105
|
const readSecretToken = (
|
|
69
106
|
workspaceId: string,
|
|
@@ -78,18 +115,39 @@ export function createJiraTokenStore(params: CreateJiraTokenStoreParams): JiraTo
|
|
|
78
115
|
|
|
79
116
|
return {
|
|
80
117
|
async storeTokens(input) {
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
118
|
+
const credentialGeneration = advanceCredentialGeneration(input.connectionId);
|
|
119
|
+
tokenRefreshes.delete(input.connectionId);
|
|
120
|
+
await withJiraRefreshLockAndWait(input.connectionId, () =>
|
|
121
|
+
withCredentialWrite(input.connectionId, async () => {
|
|
122
|
+
if (currentCredentialGeneration(input.connectionId) !== credentialGeneration) return;
|
|
123
|
+
const workspaceId = await resolveWorkspaceId(input.connectionId);
|
|
124
|
+
if (currentCredentialGeneration(input.connectionId) !== credentialGeneration) return;
|
|
125
|
+
const values: Record<string, string> = {[ACCESS_TOKEN_KEY]: input.accessToken};
|
|
126
|
+
if (input.refreshToken) values[REFRESH_TOKEN_KEY] = input.refreshToken;
|
|
127
|
+
await params.secrets.setSecrets({
|
|
128
|
+
workspaceId,
|
|
129
|
+
namespace: jiraSecretsNamespace(input.connectionId),
|
|
130
|
+
values,
|
|
131
|
+
editedBy: input.editedBy,
|
|
132
|
+
});
|
|
133
|
+
if (currentCredentialGeneration(input.connectionId) === credentialGeneration) {
|
|
134
|
+
refreshStateUnknownConnections.delete(input.connectionId);
|
|
135
|
+
}
|
|
136
|
+
}),
|
|
137
|
+
);
|
|
90
138
|
},
|
|
91
139
|
async getAccessToken(input) {
|
|
92
|
-
|
|
140
|
+
await waitForCredentialWrites(input.connectionId);
|
|
141
|
+
const connection = await resolveConnection(input.connectionId);
|
|
142
|
+
if (connection.lifecycleStatus !== 'active') {
|
|
143
|
+
throw new JiraTokenUnrefreshableError(input.connectionId);
|
|
144
|
+
}
|
|
145
|
+
await waitForCredentialWrites(input.connectionId);
|
|
146
|
+
if (refreshStateUnknownConnections.has(input.connectionId)) {
|
|
147
|
+
throw new JiraTokenUnrefreshableError(input.connectionId);
|
|
148
|
+
}
|
|
149
|
+
const credentialGeneration = currentCredentialGeneration(input.connectionId);
|
|
150
|
+
const workspaceId = connection.workspaceId;
|
|
93
151
|
const accessToken = await readAccessToken(workspaceId, input.connectionId);
|
|
94
152
|
if (
|
|
95
153
|
!input.forceRefresh &&
|
|
@@ -110,11 +168,23 @@ export function createJiraTokenStore(params: CreateJiraTokenStoreParams): JiraTo
|
|
|
110
168
|
readAccessToken,
|
|
111
169
|
readSecretToken,
|
|
112
170
|
markConnectionError: params.markConnectionError,
|
|
171
|
+
withCredentialWrite,
|
|
172
|
+
waitForCredentialWrites,
|
|
173
|
+
isCurrentCredentialGeneration: () =>
|
|
174
|
+
currentCredentialGeneration(input.connectionId) === credentialGeneration,
|
|
113
175
|
});
|
|
114
176
|
tokenRefreshes.set(input.connectionId, refresh);
|
|
115
177
|
void refresh.then(
|
|
116
178
|
() => clearTokenRefresh(input.connectionId, refresh),
|
|
117
|
-
() =>
|
|
179
|
+
(error) => {
|
|
180
|
+
if (
|
|
181
|
+
isTerminalRefreshFailure(error) &&
|
|
182
|
+
currentCredentialGeneration(input.connectionId) === credentialGeneration
|
|
183
|
+
) {
|
|
184
|
+
refreshStateUnknownConnections.add(input.connectionId);
|
|
185
|
+
}
|
|
186
|
+
clearTokenRefresh(input.connectionId, refresh);
|
|
187
|
+
},
|
|
118
188
|
);
|
|
119
189
|
return refresh;
|
|
120
190
|
},
|
|
@@ -125,10 +195,6 @@ function shouldRefresh(expiresAt: Date | null): boolean {
|
|
|
125
195
|
return expiresAt !== null && expiresAt.getTime() <= Date.now() + TOKEN_REFRESH_MARGIN_MS;
|
|
126
196
|
}
|
|
127
197
|
|
|
128
|
-
function clearTokenRefresh(connectionId: string, refresh: Promise<string>): void {
|
|
129
|
-
if (tokenRefreshes.get(connectionId) === refresh) tokenRefreshes.delete(connectionId);
|
|
130
|
-
}
|
|
131
|
-
|
|
132
198
|
async function refreshAccessTokenWithLock(params: {
|
|
133
199
|
connectionId: string;
|
|
134
200
|
workspaceId: string;
|
|
@@ -143,11 +209,17 @@ async function refreshAccessTokenWithLock(params: {
|
|
|
143
209
|
key: typeof ACCESS_TOKEN_KEY | typeof REFRESH_TOKEN_KEY,
|
|
144
210
|
): Promise<string | null>;
|
|
145
211
|
markConnectionError?: ((params: {connectionId: string}) => Promise<void>) | undefined;
|
|
212
|
+
withCredentialWrite<T>(connectionId: string, operation: () => Promise<T>): Promise<T>;
|
|
213
|
+
waitForCredentialWrites(connectionId: string): Promise<void>;
|
|
214
|
+
isCurrentCredentialGeneration(): boolean;
|
|
146
215
|
}): Promise<string> {
|
|
147
216
|
const lock = await withJiraRefreshLock(params.connectionId, () =>
|
|
148
217
|
refreshAccessTokenForConnection(params),
|
|
149
218
|
);
|
|
150
219
|
if (lock.acquired) return lock.value;
|
|
220
|
+
if (!params.isCurrentCredentialGeneration()) {
|
|
221
|
+
return params.readAccessToken(params.workspaceId, params.connectionId);
|
|
222
|
+
}
|
|
151
223
|
const reread = await params.readAccessToken(params.workspaceId, params.connectionId);
|
|
152
224
|
if (reread !== params.originalAccessToken) return reread;
|
|
153
225
|
throw new JiraIntegrationProviderError(
|
|
@@ -170,7 +242,13 @@ async function refreshAccessTokenForConnection(params: {
|
|
|
170
242
|
key: typeof ACCESS_TOKEN_KEY | typeof REFRESH_TOKEN_KEY,
|
|
171
243
|
): Promise<string | null>;
|
|
172
244
|
markConnectionError?: ((params: {connectionId: string}) => Promise<void>) | undefined;
|
|
245
|
+
withCredentialWrite<T>(connectionId: string, operation: () => Promise<T>): Promise<T>;
|
|
246
|
+
waitForCredentialWrites(connectionId: string): Promise<void>;
|
|
247
|
+
isCurrentCredentialGeneration(): boolean;
|
|
173
248
|
}): Promise<string> {
|
|
249
|
+
if (!params.isCurrentCredentialGeneration()) {
|
|
250
|
+
return params.readAccessToken(params.workspaceId, params.connectionId);
|
|
251
|
+
}
|
|
174
252
|
const current = await params.readAccessToken(params.workspaceId, params.connectionId);
|
|
175
253
|
const installation = await getJiraInstallationByConnectionId(params.connectionId);
|
|
176
254
|
if (
|
|
@@ -190,25 +268,67 @@ async function refreshAccessTokenForConnection(params: {
|
|
|
190
268
|
}
|
|
191
269
|
try {
|
|
192
270
|
const refreshed = await params.client.refreshAccessToken({refreshToken});
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
271
|
+
const refreshedRefreshToken = refreshed.refreshToken;
|
|
272
|
+
if (!refreshedRefreshToken) throw new JiraTokenUnrefreshableError(params.connectionId);
|
|
273
|
+
const persisted = await params.withCredentialWrite(params.connectionId, async () => {
|
|
274
|
+
if (!params.isCurrentCredentialGeneration()) return false;
|
|
275
|
+
await params.secrets.setSecrets({
|
|
276
|
+
workspaceId: params.workspaceId,
|
|
277
|
+
namespace: jiraSecretsNamespace(params.connectionId),
|
|
278
|
+
values: {
|
|
279
|
+
[ACCESS_TOKEN_KEY]: refreshed.accessToken,
|
|
280
|
+
[REFRESH_TOKEN_KEY]: refreshedRefreshToken,
|
|
281
|
+
},
|
|
282
|
+
});
|
|
283
|
+
if (!params.isCurrentCredentialGeneration()) return false;
|
|
284
|
+
await updateJiraInstallationTokenExpiry({
|
|
285
|
+
connectionId: params.connectionId,
|
|
286
|
+
tokenExpiresAt: refreshed.expiresAt ?? null,
|
|
287
|
+
scopes: refreshed.scopes.length > 0 ? refreshed.scopes : undefined,
|
|
288
|
+
});
|
|
289
|
+
return true;
|
|
206
290
|
});
|
|
291
|
+
if (!persisted || !params.isCurrentCredentialGeneration()) {
|
|
292
|
+
await params.waitForCredentialWrites(params.connectionId);
|
|
293
|
+
return params.readAccessToken(params.workspaceId, params.connectionId);
|
|
294
|
+
}
|
|
207
295
|
return refreshed.accessToken;
|
|
208
296
|
} catch (error) {
|
|
209
|
-
if (error
|
|
210
|
-
await
|
|
297
|
+
if (isTerminalRefreshFailure(error)) {
|
|
298
|
+
await markConnectionError({
|
|
299
|
+
callback: params.markConnectionError,
|
|
300
|
+
connectionId: params.connectionId,
|
|
301
|
+
isCurrentCredentialGeneration: params.isCurrentCredentialGeneration,
|
|
302
|
+
withCredentialWrite: params.withCredentialWrite,
|
|
303
|
+
});
|
|
211
304
|
}
|
|
212
305
|
throw error;
|
|
213
306
|
}
|
|
214
307
|
}
|
|
308
|
+
|
|
309
|
+
function isTerminalRefreshFailure(error: unknown): boolean {
|
|
310
|
+
return (
|
|
311
|
+
error instanceof JiraIntegrationProviderError &&
|
|
312
|
+
(error.reason === 'access-denied' || error.reason === 'timeout')
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async function markConnectionError(params: {
|
|
317
|
+
callback: CreateJiraTokenStoreParams['markConnectionError'];
|
|
318
|
+
connectionId: string;
|
|
319
|
+
isCurrentCredentialGeneration(): boolean;
|
|
320
|
+
withCredentialWrite<T>(connectionId: string, operation: () => Promise<T>): Promise<T>;
|
|
321
|
+
}): Promise<void> {
|
|
322
|
+
if (!params.isCurrentCredentialGeneration()) return;
|
|
323
|
+
try {
|
|
324
|
+
await params.withCredentialWrite(params.connectionId, async () => {
|
|
325
|
+
if (!params.isCurrentCredentialGeneration()) return;
|
|
326
|
+
await params.callback?.({connectionId: params.connectionId});
|
|
327
|
+
});
|
|
328
|
+
} catch (error) {
|
|
329
|
+
logger().warn(
|
|
330
|
+
{err: error, connectionId: params.connectionId},
|
|
331
|
+
'Jira connection error-state update failed after token refresh failure',
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
import {createHmac} from 'node:crypto';
|
|
2
|
+
import type {JiraWebhookEnvelopeDto} from '@shipfox/api-integration-jira-dto';
|
|
3
|
+
import {createStoredWebhookRequest, type IntegrationConnection} from '@shipfox/api-integration-spi';
|
|
4
|
+
import {signHs256} from '@shipfox/node-jwt';
|
|
5
|
+
import {createJiraWebhookProcessor} from './webhook-processor.js';
|
|
6
|
+
|
|
7
|
+
const connectionId = 'c0a8012e-0b6d-4d8f-8d5c-6d74102602b0';
|
|
8
|
+
const cloudId = 'cloud-1';
|
|
9
|
+
const receivedAt = new Date().toISOString();
|
|
10
|
+
|
|
11
|
+
function createConnection(
|
|
12
|
+
id = connectionId,
|
|
13
|
+
externalAccountId = cloudId,
|
|
14
|
+
): IntegrationConnection<'jira'> {
|
|
15
|
+
const now = new Date();
|
|
16
|
+
return {
|
|
17
|
+
id,
|
|
18
|
+
workspaceId: 'a0a8012e-0b6d-4d8f-8d5c-6d74102602b0',
|
|
19
|
+
provider: 'jira',
|
|
20
|
+
externalAccountId,
|
|
21
|
+
slug: 'jira_acme',
|
|
22
|
+
displayName: 'Jira Acme',
|
|
23
|
+
lifecycleStatus: 'active',
|
|
24
|
+
createdAt: now,
|
|
25
|
+
updatedAt: now,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function createPayload(
|
|
30
|
+
event: JiraWebhookEnvelopeDto['webhookEvent'] = 'jira:issue_created',
|
|
31
|
+
accountId = 'account-2',
|
|
32
|
+
): JiraWebhookEnvelopeDto {
|
|
33
|
+
return {
|
|
34
|
+
webhookEvent: event,
|
|
35
|
+
timestamp: Date.now(),
|
|
36
|
+
issue_event_type_name: event.startsWith('jira:') ? event.slice('jira:'.length) : 'comment',
|
|
37
|
+
issue: {
|
|
38
|
+
id: '10001',
|
|
39
|
+
key: 'ENG-1',
|
|
40
|
+
fields: {summary: 'Webhook test', status: null, assignee: null},
|
|
41
|
+
},
|
|
42
|
+
user: {accountId},
|
|
43
|
+
...(event.startsWith('jira:')
|
|
44
|
+
? {changelog: {items: []}}
|
|
45
|
+
: {
|
|
46
|
+
comment: {
|
|
47
|
+
id: '10002',
|
|
48
|
+
author: {accountId: 'account-2'},
|
|
49
|
+
body: {type: 'doc', version: 1, content: []},
|
|
50
|
+
},
|
|
51
|
+
}),
|
|
52
|
+
matchedWebhookIds: [42],
|
|
53
|
+
} as JiraWebhookEnvelopeDto;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function createRequest(
|
|
57
|
+
payload: unknown,
|
|
58
|
+
authorization = 'Bearer invalid',
|
|
59
|
+
requestReceivedAt = receivedAt,
|
|
60
|
+
requestConnectionId = connectionId,
|
|
61
|
+
jiraWebhookIdentifier?: string,
|
|
62
|
+
) {
|
|
63
|
+
const body = new TextEncoder().encode(JSON.stringify(payload));
|
|
64
|
+
return createStoredWebhookRequest({
|
|
65
|
+
requestId: crypto.randomUUID(),
|
|
66
|
+
routeId: 'jira',
|
|
67
|
+
receivedAt: requestReceivedAt,
|
|
68
|
+
rawQueryString: '',
|
|
69
|
+
headers: {
|
|
70
|
+
authorization,
|
|
71
|
+
'content-type': 'application/json',
|
|
72
|
+
...(jiraWebhookIdentifier ? {'x-atlassian-webhook-identifier': jiraWebhookIdentifier} : {}),
|
|
73
|
+
},
|
|
74
|
+
body,
|
|
75
|
+
connectionId: requestConnectionId,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function signedAuthorization(expiresIn = '1h') {
|
|
80
|
+
return await signHs256({
|
|
81
|
+
payload: {iss: 'atlassian'},
|
|
82
|
+
secret: 'test-client-secret',
|
|
83
|
+
expiresIn,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function createWrongAlgorithmToken(token: string): string {
|
|
88
|
+
const header = Buffer.from(JSON.stringify({alg: 'HS384', typ: 'JWT'})).toString('base64url');
|
|
89
|
+
const [, payload] = token.split('.');
|
|
90
|
+
const signingInput = `${header}.${payload}`;
|
|
91
|
+
const signature = createHmac('sha384', 'test-client-secret')
|
|
92
|
+
.update(signingInput)
|
|
93
|
+
.digest('base64url');
|
|
94
|
+
return `${signingInput}.${signature}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function createHarness(overrides: {published?: boolean; deduplicate?: boolean} = {}) {
|
|
98
|
+
const seenDeliveryIds = new Set<string>();
|
|
99
|
+
const publishIntegrationEventReceived = vi.fn().mockImplementation(({event}) => {
|
|
100
|
+
if (overrides.deduplicate && seenDeliveryIds.has(event.deliveryId)) {
|
|
101
|
+
return Promise.resolve({published: false});
|
|
102
|
+
}
|
|
103
|
+
seenDeliveryIds.add(event.deliveryId);
|
|
104
|
+
return Promise.resolve({published: overrides.published ?? true});
|
|
105
|
+
});
|
|
106
|
+
const recordDeliveryOnly = vi.fn().mockResolvedValue(undefined);
|
|
107
|
+
const coreDb = vi.fn(() => ({
|
|
108
|
+
transaction: async (callback: (tx: unknown) => Promise<unknown>) => await callback({}),
|
|
109
|
+
}));
|
|
110
|
+
const getIntegrationConnectionById = vi
|
|
111
|
+
.fn()
|
|
112
|
+
.mockImplementation(async (id: string) =>
|
|
113
|
+
createConnection(id, id === connectionId ? cloudId : `cloud-${id}`),
|
|
114
|
+
);
|
|
115
|
+
const getJiraInstallationByConnectionId = vi.fn().mockImplementation(async (id: string) => ({
|
|
116
|
+
connectionId: id,
|
|
117
|
+
cloudId: id === connectionId ? cloudId : `cloud-${id}`,
|
|
118
|
+
authorizingAccountId: 'account-1',
|
|
119
|
+
webhookIds: [42],
|
|
120
|
+
status: 'installed',
|
|
121
|
+
}));
|
|
122
|
+
const processor = createJiraWebhookProcessor({
|
|
123
|
+
coreDb: coreDb as never,
|
|
124
|
+
publishIntegrationEventReceived,
|
|
125
|
+
recordDeliveryOnly,
|
|
126
|
+
getIntegrationConnectionById,
|
|
127
|
+
getJiraInstallationByConnectionId,
|
|
128
|
+
});
|
|
129
|
+
return {
|
|
130
|
+
processor,
|
|
131
|
+
coreDb,
|
|
132
|
+
getIntegrationConnectionById,
|
|
133
|
+
getJiraInstallationByConnectionId,
|
|
134
|
+
publishIntegrationEventReceived,
|
|
135
|
+
recordDeliveryOnly,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
describe('Jira webhook processor', () => {
|
|
140
|
+
it('verifies, normalizes, and publishes a supported event', async () => {
|
|
141
|
+
const harness = createHarness();
|
|
142
|
+
const token = await signedAuthorization();
|
|
143
|
+
const request = await createRequest(createPayload(), `Bearer ${token}`);
|
|
144
|
+
|
|
145
|
+
const result = await harness.processor.process(request);
|
|
146
|
+
|
|
147
|
+
expect(result).toMatchObject({outcome: 'processed'});
|
|
148
|
+
expect(harness.publishIntegrationEventReceived).toHaveBeenCalledWith(
|
|
149
|
+
expect.objectContaining({
|
|
150
|
+
event: expect.objectContaining({
|
|
151
|
+
provider: 'jira',
|
|
152
|
+
event: 'jira:issue_created',
|
|
153
|
+
connectionId,
|
|
154
|
+
payload: expect.objectContaining({cloudId}),
|
|
155
|
+
}),
|
|
156
|
+
}),
|
|
157
|
+
);
|
|
158
|
+
expect(
|
|
159
|
+
harness.publishIntegrationEventReceived.mock.calls[0]?.[0].event.payload,
|
|
160
|
+
).not.toHaveProperty('authorization');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('rejects missing or tampered authorization without recording a delivery', async () => {
|
|
164
|
+
const harness = createHarness();
|
|
165
|
+
const payload = createPayload();
|
|
166
|
+
const missing = await harness.processor.process(await createRequest(payload, ''));
|
|
167
|
+
const tamperedToken = `${await signedAuthorization()}tampered`;
|
|
168
|
+
const tampered = await harness.processor.process(
|
|
169
|
+
await createRequest(payload, `Bearer ${tamperedToken}`),
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
expect(missing).toMatchObject({outcome: 'discarded', reason: 'invalid_signature'});
|
|
173
|
+
expect(tampered).toMatchObject({outcome: 'discarded', reason: 'invalid_signature'});
|
|
174
|
+
expect(harness.recordDeliveryOnly).not.toHaveBeenCalled();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('rejects tokens that are expired at Jira receipt time or use another algorithm', async () => {
|
|
178
|
+
const receiptTime = new Date('2030-01-01T00:00:00.000Z');
|
|
179
|
+
vi.useFakeTimers({now: new Date(receiptTime.getTime() - 120_000)});
|
|
180
|
+
try {
|
|
181
|
+
const expiredToken = await signedAuthorization('60s');
|
|
182
|
+
const validToken = await signedAuthorization();
|
|
183
|
+
const harness = createHarness();
|
|
184
|
+
|
|
185
|
+
const expired = await harness.processor.process(
|
|
186
|
+
createRequest(createPayload(), `Bearer ${expiredToken}`, receiptTime.toISOString()),
|
|
187
|
+
);
|
|
188
|
+
const wrongAlgorithm = await harness.processor.process(
|
|
189
|
+
createRequest(
|
|
190
|
+
createPayload(),
|
|
191
|
+
`Bearer ${createWrongAlgorithmToken(validToken)}`,
|
|
192
|
+
receiptTime.toISOString(),
|
|
193
|
+
),
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
expect(expired).toMatchObject({outcome: 'discarded', reason: 'invalid_signature'});
|
|
197
|
+
expect(wrongAlgorithm).toMatchObject({outcome: 'discarded', reason: 'invalid_signature'});
|
|
198
|
+
expect(harness.recordDeliveryOnly).not.toHaveBeenCalled();
|
|
199
|
+
expect(harness.publishIntegrationEventReceived).not.toHaveBeenCalled();
|
|
200
|
+
} finally {
|
|
201
|
+
vi.useRealTimers();
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('records authenticated deliberate drops for mismatched and self-authored deliveries', async () => {
|
|
206
|
+
const harness = createHarness();
|
|
207
|
+
const token = await signedAuthorization();
|
|
208
|
+
const mismatch = await createRequest(
|
|
209
|
+
{...createPayload(), matchedWebhookIds: [99]},
|
|
210
|
+
`Bearer ${token}`,
|
|
211
|
+
);
|
|
212
|
+
const selfAuthored = await createRequest(
|
|
213
|
+
createPayload('comment_created', 'account-1'),
|
|
214
|
+
`Bearer ${token}`,
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
const mismatchResult = await harness.processor.process(mismatch);
|
|
218
|
+
const selfAuthoredResult = await harness.processor.process(selfAuthored);
|
|
219
|
+
|
|
220
|
+
expect(mismatchResult).toMatchObject({outcome: 'discarded', reason: 'connection_unavailable'});
|
|
221
|
+
expect(selfAuthoredResult).toMatchObject({outcome: 'discarded', reason: 'unsupported_event'});
|
|
222
|
+
expect(harness.recordDeliveryOnly).toHaveBeenCalledTimes(2);
|
|
223
|
+
expect(harness.publishIntegrationEventReceived).not.toHaveBeenCalled();
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it.each([
|
|
227
|
+
'comment_updated',
|
|
228
|
+
'jira:issue_updated',
|
|
229
|
+
] as const)('drops self-authored %s events without publishing', async (event) => {
|
|
230
|
+
const harness = createHarness();
|
|
231
|
+
const token = await signedAuthorization();
|
|
232
|
+
|
|
233
|
+
const result = await harness.processor.process(
|
|
234
|
+
createRequest(createPayload(event, 'account-1'), `Bearer ${token}`),
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
expect(result).toMatchObject({outcome: 'discarded', reason: 'unsupported_event'});
|
|
238
|
+
expect(harness.recordDeliveryOnly).toHaveBeenCalledOnce();
|
|
239
|
+
expect(harness.publishIntegrationEventReceived).not.toHaveBeenCalled();
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('publishes a self-authored comment_deleted event', async () => {
|
|
243
|
+
const harness = createHarness();
|
|
244
|
+
const token = await signedAuthorization();
|
|
245
|
+
|
|
246
|
+
const result = await harness.processor.process(
|
|
247
|
+
createRequest(createPayload('comment_deleted', 'account-1'), `Bearer ${token}`),
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
expect(result).toMatchObject({outcome: 'processed'});
|
|
251
|
+
expect(harness.publishIntegrationEventReceived).toHaveBeenCalledOnce();
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('records unsupported events and rejects missing, wrong-provider, inactive, and revoked connections', async () => {
|
|
255
|
+
const token = await signedAuthorization();
|
|
256
|
+
const unsupported = createHarness();
|
|
257
|
+
const unsupportedResult = await unsupported.processor.process(
|
|
258
|
+
createRequest({...createPayload(), webhookEvent: 'jira:unknown'}, `Bearer ${token}`),
|
|
259
|
+
);
|
|
260
|
+
expect(unsupportedResult).toMatchObject({outcome: 'discarded', reason: 'unsupported_event'});
|
|
261
|
+
expect(unsupported.recordDeliveryOnly).toHaveBeenCalledOnce();
|
|
262
|
+
|
|
263
|
+
const missing = createHarness();
|
|
264
|
+
missing.getIntegrationConnectionById.mockResolvedValue(undefined);
|
|
265
|
+
const missingResult = await missing.processor.process(
|
|
266
|
+
createRequest(createPayload(), `Bearer ${token}`),
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
const missingInstallation = createHarness();
|
|
270
|
+
missingInstallation.getJiraInstallationByConnectionId.mockResolvedValue(undefined);
|
|
271
|
+
const missingInstallationResult = await missingInstallation.processor.process(
|
|
272
|
+
createRequest(createPayload(), `Bearer ${token}`),
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
const wrongProvider = createHarness();
|
|
276
|
+
wrongProvider.getIntegrationConnectionById.mockResolvedValue({
|
|
277
|
+
...createConnection(),
|
|
278
|
+
provider: 'github',
|
|
279
|
+
});
|
|
280
|
+
const wrongProviderResult = await wrongProvider.processor.process(
|
|
281
|
+
createRequest(createPayload(), `Bearer ${token}`),
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
const inactive = createHarness();
|
|
285
|
+
inactive.getIntegrationConnectionById.mockResolvedValue({
|
|
286
|
+
...createConnection(),
|
|
287
|
+
lifecycleStatus: 'error',
|
|
288
|
+
});
|
|
289
|
+
const inactiveResult = await inactive.processor.process(
|
|
290
|
+
createRequest(createPayload(), `Bearer ${token}`),
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
const revoked = createHarness();
|
|
294
|
+
revoked.getJiraInstallationByConnectionId.mockResolvedValue({
|
|
295
|
+
connectionId,
|
|
296
|
+
cloudId,
|
|
297
|
+
authorizingAccountId: 'account-1',
|
|
298
|
+
webhookIds: [42],
|
|
299
|
+
status: 'revoked',
|
|
300
|
+
});
|
|
301
|
+
const revokedResult = await revoked.processor.process(
|
|
302
|
+
createRequest(createPayload(), `Bearer ${token}`),
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
for (const result of [
|
|
306
|
+
missingResult,
|
|
307
|
+
missingInstallationResult,
|
|
308
|
+
wrongProviderResult,
|
|
309
|
+
inactiveResult,
|
|
310
|
+
revokedResult,
|
|
311
|
+
]) {
|
|
312
|
+
expect(result).toMatchObject({outcome: 'discarded', reason: 'connection_unavailable'});
|
|
313
|
+
}
|
|
314
|
+
for (const harness of [missing, missingInstallation, wrongProvider, inactive, revoked]) {
|
|
315
|
+
expect(harness.recordDeliveryOnly).toHaveBeenCalledOnce();
|
|
316
|
+
expect(harness.publishIntegrationEventReceived).not.toHaveBeenCalled();
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('returns duplicate for a repeated body hash without publishing twice', async () => {
|
|
321
|
+
const harness = createHarness({published: false});
|
|
322
|
+
const token = await signedAuthorization();
|
|
323
|
+
const request = await createRequest(createPayload(), `Bearer ${token}`);
|
|
324
|
+
|
|
325
|
+
const result = await harness.processor.process(request);
|
|
326
|
+
|
|
327
|
+
expect(result).toMatchObject({outcome: 'duplicate'});
|
|
328
|
+
expect(harness.publishIntegrationEventReceived).toHaveBeenCalledOnce();
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it('uses Jira’s stable delivery identifier when it is present', async () => {
|
|
332
|
+
const harness = createHarness({deduplicate: true});
|
|
333
|
+
const token = await signedAuthorization();
|
|
334
|
+
const firstPayload = createPayload();
|
|
335
|
+
const replayPayload = {...firstPayload, timestamp: firstPayload.timestamp + 1};
|
|
336
|
+
|
|
337
|
+
const first = await harness.processor.process(
|
|
338
|
+
createRequest(firstPayload, `Bearer ${token}`, receivedAt, connectionId, 'jira-delivery-1'),
|
|
339
|
+
);
|
|
340
|
+
const replay = await harness.processor.process(
|
|
341
|
+
createRequest(replayPayload, `Bearer ${token}`, receivedAt, connectionId, 'jira-delivery-1'),
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
expect(first).toMatchObject({
|
|
345
|
+
outcome: 'processed',
|
|
346
|
+
deliveryId: `${connectionId}:jira-delivery-1`,
|
|
347
|
+
});
|
|
348
|
+
expect(replay).toMatchObject({
|
|
349
|
+
outcome: 'duplicate',
|
|
350
|
+
deliveryId: `${connectionId}:jira-delivery-1`,
|
|
351
|
+
});
|
|
352
|
+
expect(harness.publishIntegrationEventReceived).toHaveBeenCalledTimes(2);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it('scopes delivery deduplication by connection and still rejects a replay on that connection', async () => {
|
|
356
|
+
const harness = createHarness({deduplicate: true});
|
|
357
|
+
const token = await signedAuthorization();
|
|
358
|
+
const otherConnectionId = crypto.randomUUID();
|
|
359
|
+
const payload = createPayload();
|
|
360
|
+
|
|
361
|
+
const first = await harness.processor.process(createRequest(payload, `Bearer ${token}`));
|
|
362
|
+
const replay = await harness.processor.process(createRequest(payload, `Bearer ${token}`));
|
|
363
|
+
const otherConnection = await harness.processor.process(
|
|
364
|
+
createRequest(payload, `Bearer ${token}`, receivedAt, otherConnectionId),
|
|
365
|
+
);
|
|
366
|
+
|
|
367
|
+
expect(first).toMatchObject({outcome: 'processed'});
|
|
368
|
+
expect(replay).toMatchObject({outcome: 'duplicate'});
|
|
369
|
+
expect(otherConnection).toMatchObject({outcome: 'processed'});
|
|
370
|
+
const events = harness.publishIntegrationEventReceived.mock.calls.map(([input]) => input.event);
|
|
371
|
+
expect(events).toHaveLength(3);
|
|
372
|
+
expect(events[0]?.deliveryId).not.toBe(events[2]?.deliveryId);
|
|
373
|
+
expect(events[0]?.connectionId).toBe(connectionId);
|
|
374
|
+
expect(events[2]?.connectionId).toBe(otherConnectionId);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it('accepts a JWT that was valid when Jira delivered the request', async () => {
|
|
378
|
+
const receiptTime = new Date('2030-01-01T00:00:00.000Z');
|
|
379
|
+
vi.useFakeTimers({now: receiptTime});
|
|
380
|
+
try {
|
|
381
|
+
const token = await signHs256({
|
|
382
|
+
payload: {iss: 'atlassian'},
|
|
383
|
+
secret: 'test-client-secret',
|
|
384
|
+
expiresIn: '60s',
|
|
385
|
+
});
|
|
386
|
+
vi.setSystemTime(new Date(receiptTime.getTime() + 120_000));
|
|
387
|
+
|
|
388
|
+
const harness = createHarness();
|
|
389
|
+
const result = await harness.processor.process(
|
|
390
|
+
createRequest(createPayload(), `Bearer ${token}`, receiptTime.toISOString()),
|
|
391
|
+
);
|
|
392
|
+
|
|
393
|
+
expect(result).toMatchObject({outcome: 'processed'});
|
|
394
|
+
} finally {
|
|
395
|
+
vi.useRealTimers();
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it('returns malformed payload after authenticating before parsing succeeds', async () => {
|
|
400
|
+
const harness = createHarness();
|
|
401
|
+
const token = await signedAuthorization();
|
|
402
|
+
const request = createStoredWebhookRequest({
|
|
403
|
+
requestId: crypto.randomUUID(),
|
|
404
|
+
routeId: 'jira',
|
|
405
|
+
receivedAt,
|
|
406
|
+
rawQueryString: '',
|
|
407
|
+
headers: {authorization: `Bearer ${token}`},
|
|
408
|
+
body: new TextEncoder().encode('{'),
|
|
409
|
+
connectionId,
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
await expect(harness.processor.process(request)).resolves.toMatchObject({
|
|
413
|
+
outcome: 'discarded',
|
|
414
|
+
reason: 'malformed_payload',
|
|
415
|
+
});
|
|
416
|
+
expect(harness.recordDeliveryOnly).not.toHaveBeenCalled();
|
|
417
|
+
});
|
|
418
|
+
});
|