@shipfox/api-integration-jira 10.2.0 → 12.0.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 +1 -1
- package/CHANGELOG.md +33 -0
- package/README.md +1 -1
- package/dist/api/client.d.ts +15 -0
- package/dist/api/client.d.ts.map +1 -1
- package/dist/api/client.js +53 -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/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/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/installations.d.ts +11 -0
- package/dist/db/installations.d.ts.map +1 -1
- package/dist/db/installations.js +54 -5
- package/dist/db/installations.js.map +1 -1
- package/dist/index.d.ts +17 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +61 -5
- 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/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +9 -7
- package/src/api/client.test.ts +106 -0
- package/src/api/client.ts +85 -0
- package/src/config.test.ts +0 -1
- package/src/config.ts +1 -4
- package/src/core/install.test.ts +179 -0
- package/src/core/install.ts +85 -19
- package/src/core/tokens-refresh.test.ts +2 -0
- 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 +64 -0
- package/src/db/installations.ts +71 -5
- package/src/index.test.ts +8 -0
- package/src/index.ts +102 -3
- 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/test/env.ts +0 -1
- package/tsconfig.build.tsbuildinfo +1 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shipfox/api-integration-jira",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "12.0.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/ShipfoxHQ/shipfox.git",
|
|
@@ -20,14 +20,16 @@
|
|
|
20
20
|
"dependencies": {
|
|
21
21
|
"drizzle-orm": "^0.45.2",
|
|
22
22
|
"ky": "^2.0.0",
|
|
23
|
-
"
|
|
24
|
-
"@shipfox/api-
|
|
25
|
-
"@shipfox/api-integration-
|
|
23
|
+
"zod": "^4.4.3",
|
|
24
|
+
"@shipfox/api-auth-context": "12.0.0",
|
|
25
|
+
"@shipfox/api-integration-spi": "1.0.0",
|
|
26
26
|
"@shipfox/config": "1.2.4",
|
|
27
|
-
"@shipfox/node-drizzle": "0.3.
|
|
28
|
-
"@shipfox/
|
|
27
|
+
"@shipfox/node-drizzle": "0.3.5",
|
|
28
|
+
"@shipfox/api-integration-jira-dto": "12.0.0",
|
|
29
|
+
"@shipfox/node-fastify": "0.4.1",
|
|
29
30
|
"@shipfox/node-opentelemetry": "0.6.3",
|
|
30
|
-
"@shipfox/node-postgres": "0.
|
|
31
|
+
"@shipfox/node-postgres": "0.5.0",
|
|
32
|
+
"@shipfox/node-jwt": "0.4.0"
|
|
31
33
|
},
|
|
32
34
|
"imports": {
|
|
33
35
|
"#*": "./dist/*"
|
package/src/api/client.test.ts
CHANGED
|
@@ -2,6 +2,28 @@ import {HTTPError} from 'ky';
|
|
|
2
2
|
import type {JiraIntegrationProviderError} from '#core/errors.js';
|
|
3
3
|
import {mapJiraError} from './client.js';
|
|
4
4
|
|
|
5
|
+
const mocks = vi.hoisted(() => ({delete: vi.fn(), post: vi.fn()}));
|
|
6
|
+
|
|
7
|
+
vi.mock('ky', () => {
|
|
8
|
+
class MockHTTPError extends Error {
|
|
9
|
+
constructor(public response: {status: number; statusText?: string; headers: Headers}) {
|
|
10
|
+
super('http');
|
|
11
|
+
this.name = 'HTTPError';
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
class MockTimeoutError extends Error {
|
|
15
|
+
constructor() {
|
|
16
|
+
super('timeout');
|
|
17
|
+
this.name = 'TimeoutError';
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return {
|
|
21
|
+
default: {delete: mocks.delete, post: mocks.post},
|
|
22
|
+
HTTPError: MockHTTPError,
|
|
23
|
+
TimeoutError: MockTimeoutError,
|
|
24
|
+
};
|
|
25
|
+
});
|
|
26
|
+
|
|
5
27
|
function rejectedRequest(status: number): () => Promise<never> {
|
|
6
28
|
return () =>
|
|
7
29
|
Promise.reject(
|
|
@@ -27,3 +49,87 @@ describe('mapJiraError', () => {
|
|
|
27
49
|
} satisfies Partial<JiraIntegrationProviderError>);
|
|
28
50
|
});
|
|
29
51
|
});
|
|
52
|
+
|
|
53
|
+
function resolves(data: unknown) {
|
|
54
|
+
return {json: () => Promise.resolve(data)};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe('Jira dynamic webhook API', () => {
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
mocks.delete.mockReset();
|
|
60
|
+
mocks.post.mockReset();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('registers the six curated events with the access token', async () => {
|
|
64
|
+
mocks.post.mockReturnValue(
|
|
65
|
+
resolves({webhookRegistrationResult: [{createdWebhookId: 123, errors: []}]}),
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const {createJiraApiClient} = await import('./client.js');
|
|
69
|
+
const result = await createJiraApiClient().registerDynamicWebhook({
|
|
70
|
+
accessToken: 'access-token',
|
|
71
|
+
cloudId: 'cloud-1',
|
|
72
|
+
url: 'https://shipfox.example.com/webhooks/integrations/jira/connection-1',
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const [url, options] = mocks.post.mock.calls[0] as [
|
|
76
|
+
string,
|
|
77
|
+
{headers: Record<string, string>; json: Record<string, unknown>},
|
|
78
|
+
];
|
|
79
|
+
expect(url).toBe('http://127.0.0.1:0/ex/jira/cloud-1/rest/api/3/webhook');
|
|
80
|
+
expect(options.headers).toEqual({authorization: 'Bearer access-token'});
|
|
81
|
+
expect(options.json).toEqual({
|
|
82
|
+
url: 'https://shipfox.example.com/webhooks/integrations/jira/connection-1',
|
|
83
|
+
webhooks: [
|
|
84
|
+
{
|
|
85
|
+
events: [
|
|
86
|
+
'jira:issue_created',
|
|
87
|
+
'jira:issue_updated',
|
|
88
|
+
'jira:issue_deleted',
|
|
89
|
+
'comment_created',
|
|
90
|
+
'comment_updated',
|
|
91
|
+
'comment_deleted',
|
|
92
|
+
],
|
|
93
|
+
jqlFilter: '',
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
});
|
|
97
|
+
expect(result).toEqual({webhookId: 123});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it.each([
|
|
101
|
+
{webhookRegistrationResult: []},
|
|
102
|
+
{webhookRegistrationResult: [{createdWebhookId: 123, errors: ['rejected']}]},
|
|
103
|
+
{webhookRegistrationResult: [{errors: []}]},
|
|
104
|
+
])('rejects malformed or errored registration responses', async (response) => {
|
|
105
|
+
mocks.post.mockReturnValue(resolves(response));
|
|
106
|
+
const {createJiraApiClient} = await import('./client.js');
|
|
107
|
+
|
|
108
|
+
await expect(
|
|
109
|
+
createJiraApiClient().registerDynamicWebhook({
|
|
110
|
+
accessToken: 'access-token',
|
|
111
|
+
cloudId: 'cloud-1',
|
|
112
|
+
url: 'https://shipfox.example.com/webhooks/integrations/jira/connection-1',
|
|
113
|
+
}),
|
|
114
|
+
).rejects.toMatchObject({reason: 'malformed-provider-response'});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('deletes a dynamic webhook by id', async () => {
|
|
118
|
+
mocks.delete.mockResolvedValue(undefined);
|
|
119
|
+
const {createJiraApiClient} = await import('./client.js');
|
|
120
|
+
|
|
121
|
+
await createJiraApiClient().deleteDynamicWebhook({
|
|
122
|
+
accessToken: 'access-token',
|
|
123
|
+
cloudId: 'cloud-1',
|
|
124
|
+
webhookId: 123,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
expect(mocks.delete).toHaveBeenCalledWith(
|
|
128
|
+
'http://127.0.0.1:0/ex/jira/cloud-1/rest/api/3/webhook',
|
|
129
|
+
expect.objectContaining({
|
|
130
|
+
headers: {authorization: 'Bearer access-token'},
|
|
131
|
+
json: {webhookIds: [123]},
|
|
132
|
+
}),
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
});
|
package/src/api/client.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {jiraWebhookEventNames} from '@shipfox/api-integration-jira-dto';
|
|
1
2
|
import {logger} from '@shipfox/node-opentelemetry';
|
|
2
3
|
import ky, {HTTPError, TimeoutError} from 'ky';
|
|
3
4
|
import {config} from '#config.js';
|
|
@@ -5,6 +6,8 @@ import {JiraIntegrationProviderError} from '#core/errors.js';
|
|
|
5
6
|
|
|
6
7
|
const JIRA_API_TIMEOUT_MS = 10_000;
|
|
7
8
|
const SCOPE_SEPARATOR_RE = /[,\s]+/;
|
|
9
|
+
export const JIRA_DYNAMIC_WEBHOOK_EVENTS = jiraWebhookEventNames;
|
|
10
|
+
export const JIRA_DYNAMIC_WEBHOOK_JQL = '';
|
|
8
11
|
|
|
9
12
|
export interface JiraAuthorization {
|
|
10
13
|
accessToken: string;
|
|
@@ -24,11 +27,25 @@ export interface JiraIdentity {
|
|
|
24
27
|
accountId: string;
|
|
25
28
|
}
|
|
26
29
|
|
|
30
|
+
export interface JiraDynamicWebhookRegistration {
|
|
31
|
+
webhookId: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
27
34
|
export interface JiraApiClient {
|
|
28
35
|
exchangeAuthorizationCode(input: {code: string}): Promise<JiraAuthorization>;
|
|
29
36
|
refreshAccessToken(input: {refreshToken: string}): Promise<JiraAuthorization>;
|
|
30
37
|
getAccessibleResources(input: {accessToken: string}): Promise<JiraAccessibleResource[]>;
|
|
31
38
|
getMyself(input: {accessToken: string; cloudId: string}): Promise<JiraIdentity>;
|
|
39
|
+
registerDynamicWebhook(input: {
|
|
40
|
+
accessToken: string;
|
|
41
|
+
cloudId: string;
|
|
42
|
+
url: string;
|
|
43
|
+
}): Promise<JiraDynamicWebhookRegistration>;
|
|
44
|
+
deleteDynamicWebhook(input: {
|
|
45
|
+
accessToken: string;
|
|
46
|
+
cloudId: string;
|
|
47
|
+
webhookId: number;
|
|
48
|
+
}): Promise<void>;
|
|
32
49
|
}
|
|
33
50
|
|
|
34
51
|
interface JiraTokenResponse {
|
|
@@ -49,6 +66,10 @@ interface JiraMyselfResponse {
|
|
|
49
66
|
accountId?: unknown;
|
|
50
67
|
}
|
|
51
68
|
|
|
69
|
+
interface JiraDynamicWebhookRegistrationResponse {
|
|
70
|
+
webhookRegistrationResult?: unknown;
|
|
71
|
+
}
|
|
72
|
+
|
|
52
73
|
export function createJiraApiClient(): JiraApiClient {
|
|
53
74
|
return {
|
|
54
75
|
async exchangeAuthorizationCode(input) {
|
|
@@ -115,9 +136,73 @@ export function createJiraApiClient(): JiraApiClient {
|
|
|
115
136
|
}
|
|
116
137
|
return {accountId: body.accountId};
|
|
117
138
|
},
|
|
139
|
+
|
|
140
|
+
async registerDynamicWebhook(input) {
|
|
141
|
+
const body = await mapJiraError('register-dynamic-webhook', () =>
|
|
142
|
+
ky
|
|
143
|
+
.post(`${config.JIRA_API_BASE_URL}/ex/jira/${input.cloudId}/rest/api/3/webhook`, {
|
|
144
|
+
headers: {authorization: `Bearer ${input.accessToken}`},
|
|
145
|
+
json: {
|
|
146
|
+
url: input.url,
|
|
147
|
+
webhooks: [
|
|
148
|
+
{
|
|
149
|
+
events: JIRA_DYNAMIC_WEBHOOK_EVENTS,
|
|
150
|
+
jqlFilter: JIRA_DYNAMIC_WEBHOOK_JQL,
|
|
151
|
+
},
|
|
152
|
+
],
|
|
153
|
+
},
|
|
154
|
+
timeout: JIRA_API_TIMEOUT_MS,
|
|
155
|
+
})
|
|
156
|
+
.json<JiraDynamicWebhookRegistrationResponse>(),
|
|
157
|
+
);
|
|
158
|
+
return parseDynamicWebhookRegistration(body);
|
|
159
|
+
},
|
|
160
|
+
|
|
161
|
+
async deleteDynamicWebhook(input) {
|
|
162
|
+
await mapJiraError('delete-dynamic-webhook', () =>
|
|
163
|
+
ky.delete(`${config.JIRA_API_BASE_URL}/ex/jira/${input.cloudId}/rest/api/3/webhook`, {
|
|
164
|
+
headers: {authorization: `Bearer ${input.accessToken}`},
|
|
165
|
+
json: {webhookIds: [input.webhookId]},
|
|
166
|
+
timeout: JIRA_API_TIMEOUT_MS,
|
|
167
|
+
}),
|
|
168
|
+
);
|
|
169
|
+
},
|
|
118
170
|
};
|
|
119
171
|
}
|
|
120
172
|
|
|
173
|
+
function parseDynamicWebhookRegistration(
|
|
174
|
+
body: JiraDynamicWebhookRegistrationResponse,
|
|
175
|
+
): JiraDynamicWebhookRegistration {
|
|
176
|
+
if (
|
|
177
|
+
!body ||
|
|
178
|
+
typeof body !== 'object' ||
|
|
179
|
+
!Array.isArray(body.webhookRegistrationResult) ||
|
|
180
|
+
body.webhookRegistrationResult.length !== 1
|
|
181
|
+
) {
|
|
182
|
+
throw malformed('Jira webhook registration response did not contain exactly one result');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const result = body.webhookRegistrationResult[0];
|
|
186
|
+
if (!result || typeof result !== 'object') {
|
|
187
|
+
throw malformed('Jira webhook registration result was malformed');
|
|
188
|
+
}
|
|
189
|
+
const {createdWebhookId, errors} = result as {
|
|
190
|
+
createdWebhookId?: unknown;
|
|
191
|
+
errors?: unknown;
|
|
192
|
+
};
|
|
193
|
+
if (errors !== undefined && (!Array.isArray(errors) || errors.length > 0)) {
|
|
194
|
+
throw malformed('Jira webhook registration returned errors');
|
|
195
|
+
}
|
|
196
|
+
if (
|
|
197
|
+
typeof createdWebhookId !== 'number' ||
|
|
198
|
+
!Number.isSafeInteger(createdWebhookId) ||
|
|
199
|
+
createdWebhookId <= 0
|
|
200
|
+
) {
|
|
201
|
+
throw malformed('Jira webhook registration did not return a created webhook id');
|
|
202
|
+
}
|
|
203
|
+
return {webhookId: createdWebhookId};
|
|
204
|
+
}
|
|
205
|
+
|
|
121
206
|
function parseAuthorization(body: JiraTokenResponse): JiraAuthorization {
|
|
122
207
|
if (typeof body.access_token !== 'string' || body.access_token.length === 0) {
|
|
123
208
|
throw malformed('Jira authorization response did not include an access token');
|
package/src/config.test.ts
CHANGED
package/src/config.ts
CHANGED
|
@@ -5,14 +5,11 @@ export const config = createConfig({
|
|
|
5
5
|
desc: 'OAuth client ID of the Jira app, used to start and complete the Jira connect flow. Required.',
|
|
6
6
|
}),
|
|
7
7
|
JIRA_OAUTH_CLIENT_SECRET: str({
|
|
8
|
-
desc: 'OAuth client secret of the Jira app, used
|
|
8
|
+
desc: 'OAuth client secret of the Jira app, used for authorization-code exchange and inbound webhook JWT verification. Required.',
|
|
9
9
|
}),
|
|
10
10
|
JIRA_OAUTH_REDIRECT_URL: url({
|
|
11
11
|
desc: 'Public client callback URL Jira redirects to after OAuth authorization, such as https://shipfox.example.com/integrations/jira/callback. Required.',
|
|
12
12
|
}),
|
|
13
|
-
JIRA_WEBHOOK_SIGNING_SECRET: str({
|
|
14
|
-
desc: 'Secret used to verify incoming Jira webhooks. It must match the webhook signing secret configured for the Jira app. Required.',
|
|
15
|
-
}),
|
|
16
13
|
JIRA_WEBHOOK_BASE_URL: url({
|
|
17
14
|
desc: 'Public base URL Jira sends webhooks to. Its origin must be added to the Jira app webhook allowlist. Required.',
|
|
18
15
|
}),
|
package/src/core/install.test.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {type JiraInstallationLock, withJiraInstallationLock} from '#db/installations.js';
|
|
1
2
|
import {
|
|
2
3
|
JiraOfflineAccessNotGrantedError,
|
|
3
4
|
JiraPendingSelectionNotFoundError,
|
|
@@ -19,6 +20,8 @@ function createParams() {
|
|
|
19
20
|
getAccessibleResources: vi.fn(),
|
|
20
21
|
getMyself: vi.fn().mockResolvedValue({accountId: 'account-1'}),
|
|
21
22
|
refreshAccessToken: vi.fn(),
|
|
23
|
+
registerDynamicWebhook: vi.fn(),
|
|
24
|
+
deleteDynamicWebhook: vi.fn(),
|
|
22
25
|
};
|
|
23
26
|
const tokenStore = {storeTokens: vi.fn().mockResolvedValue(undefined)};
|
|
24
27
|
const pendingStore = {
|
|
@@ -29,6 +32,12 @@ function createParams() {
|
|
|
29
32
|
const connectJiraInstallation = vi
|
|
30
33
|
.fn()
|
|
31
34
|
.mockResolvedValue({id: 'connection-1', workspaceId, provider: 'jira'});
|
|
35
|
+
const registerJiraWebhook = vi.fn().mockImplementation(async ({onRegistrationSuccess}) => {
|
|
36
|
+
await onRegistrationSuccess?.({});
|
|
37
|
+
});
|
|
38
|
+
const markConnectionActive = vi.fn().mockResolvedValue(undefined);
|
|
39
|
+
const markConnectionError = vi.fn().mockResolvedValue(undefined);
|
|
40
|
+
const withJiraInstallationLock: JiraInstallationLock = async (_key, fn) => fn();
|
|
32
41
|
return {
|
|
33
42
|
workspaceId,
|
|
34
43
|
state,
|
|
@@ -36,6 +45,10 @@ function createParams() {
|
|
|
36
45
|
tokenStore,
|
|
37
46
|
pendingStore,
|
|
38
47
|
connectJiraInstallation,
|
|
48
|
+
registerJiraWebhook,
|
|
49
|
+
markConnectionActive,
|
|
50
|
+
markConnectionError,
|
|
51
|
+
withJiraInstallationLock,
|
|
39
52
|
code: 'code',
|
|
40
53
|
sessionUserId: 'user-1',
|
|
41
54
|
sessionMemberships: [],
|
|
@@ -66,6 +79,7 @@ describe('Jira OAuth installation', () => {
|
|
|
66
79
|
expect(params.tokenStore.storeTokens).toHaveBeenCalledWith(
|
|
67
80
|
expect.objectContaining({connectionId: 'connection-1', refreshToken: 'refresh'}),
|
|
68
81
|
);
|
|
82
|
+
expect(params.markConnectionActive).toHaveBeenCalledWith({connectionId: 'connection-1'});
|
|
69
83
|
});
|
|
70
84
|
|
|
71
85
|
it('stores a multi-site grant until the selected site completes', async () => {
|
|
@@ -126,4 +140,169 @@ describe('Jira OAuth installation', () => {
|
|
|
126
140
|
handleJiraSiteSelection({...mismatchedSite, cloudId: 'cloud-1'}),
|
|
127
141
|
).rejects.toBeInstanceOf(JiraSiteSelectionMismatchError);
|
|
128
142
|
});
|
|
143
|
+
|
|
144
|
+
it('marks the connection in error while retaining state when registration fails', async () => {
|
|
145
|
+
const params = createParams();
|
|
146
|
+
params.jira.getAccessibleResources.mockResolvedValue([
|
|
147
|
+
{
|
|
148
|
+
cloudId: 'cloud-1',
|
|
149
|
+
name: 'Acme',
|
|
150
|
+
url: 'https://acme.atlassian.net',
|
|
151
|
+
scopes: ['read:jira-work'],
|
|
152
|
+
},
|
|
153
|
+
]);
|
|
154
|
+
const registrationError = new Error('Jira rejected the webhook');
|
|
155
|
+
params.registerJiraWebhook.mockRejectedValue(registrationError);
|
|
156
|
+
await expect(handleJiraCallback(params)).rejects.toBe(registrationError);
|
|
157
|
+
expect(params.tokenStore.storeTokens).toHaveBeenCalled();
|
|
158
|
+
expect(params.disconnectJiraInstallation).not.toHaveBeenCalled();
|
|
159
|
+
expect(params.markConnectionError).toHaveBeenCalledWith({connectionId: 'connection-1'});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('does not overwrite an existing installation when token storage fails', async () => {
|
|
163
|
+
const params = createParams();
|
|
164
|
+
params.jira.getAccessibleResources.mockResolvedValue([
|
|
165
|
+
{
|
|
166
|
+
cloudId: 'cloud-1',
|
|
167
|
+
name: 'Acme',
|
|
168
|
+
url: 'https://acme.atlassian.net',
|
|
169
|
+
scopes: ['read:jira-work'],
|
|
170
|
+
},
|
|
171
|
+
]);
|
|
172
|
+
const existing = {
|
|
173
|
+
id: 'connection-1',
|
|
174
|
+
workspaceId: params.workspaceId,
|
|
175
|
+
provider: 'jira',
|
|
176
|
+
} as const;
|
|
177
|
+
params.getExistingJiraConnection.mockResolvedValue(existing);
|
|
178
|
+
const storageError = new Error('secret storage unavailable');
|
|
179
|
+
params.tokenStore.storeTokens.mockRejectedValue(storageError);
|
|
180
|
+
params.markConnectionError
|
|
181
|
+
.mockRejectedValueOnce(new Error('database unavailable'))
|
|
182
|
+
.mockResolvedValueOnce(undefined);
|
|
183
|
+
|
|
184
|
+
await expect(handleJiraCallback(params)).rejects.toBe(storageError);
|
|
185
|
+
|
|
186
|
+
expect(params.connectJiraInstallation).not.toHaveBeenCalled();
|
|
187
|
+
expect(params.markConnectionError).toHaveBeenCalledTimes(2);
|
|
188
|
+
expect(params.markConnectionError).toHaveBeenNthCalledWith(1, {connectionId: existing.id});
|
|
189
|
+
expect(params.markConnectionError).toHaveBeenNthCalledWith(2, {connectionId: existing.id});
|
|
190
|
+
expect(params.disconnectJiraInstallation).not.toHaveBeenCalled();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('falls back to a second error-state write when the registration callback cannot persist it', async () => {
|
|
194
|
+
const params = createParams();
|
|
195
|
+
params.jira.getAccessibleResources.mockResolvedValue([
|
|
196
|
+
{
|
|
197
|
+
cloudId: 'cloud-1',
|
|
198
|
+
name: 'Acme',
|
|
199
|
+
url: 'https://acme.atlassian.net',
|
|
200
|
+
scopes: ['read:jira-work'],
|
|
201
|
+
},
|
|
202
|
+
]);
|
|
203
|
+
const registrationError = new Error('Jira rejected the webhook');
|
|
204
|
+
params.registerJiraWebhook.mockImplementation(async ({onRegistrationFailure}) => {
|
|
205
|
+
await onRegistrationFailure?.({});
|
|
206
|
+
throw registrationError;
|
|
207
|
+
});
|
|
208
|
+
params.markConnectionError
|
|
209
|
+
.mockRejectedValueOnce(new Error('database unavailable'))
|
|
210
|
+
.mockResolvedValueOnce(undefined);
|
|
211
|
+
|
|
212
|
+
await expect(handleJiraCallback(params)).rejects.toBe(registrationError);
|
|
213
|
+
|
|
214
|
+
expect(params.markConnectionError).toHaveBeenCalledTimes(2);
|
|
215
|
+
expect(params.markConnectionError).toHaveBeenNthCalledWith(1, {connectionId: 'connection-1'});
|
|
216
|
+
expect(params.markConnectionError).toHaveBeenNthCalledWith(2, {connectionId: 'connection-1'});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('serializes the complete same-site replacement before allowing another callback to proceed', async () => {
|
|
220
|
+
const first = createParams();
|
|
221
|
+
const second = createParams();
|
|
222
|
+
second.workspaceId = first.workspaceId;
|
|
223
|
+
second.state = signJiraInstallState({workspaceId: first.workspaceId, userId: 'user-1'});
|
|
224
|
+
const sites = [
|
|
225
|
+
{
|
|
226
|
+
cloudId: 'cloud-1',
|
|
227
|
+
name: 'Acme',
|
|
228
|
+
url: 'https://acme.atlassian.net',
|
|
229
|
+
scopes: ['read:jira-work'],
|
|
230
|
+
},
|
|
231
|
+
];
|
|
232
|
+
const connection = {id: 'connection-1', workspaceId: first.workspaceId, provider: 'jira'};
|
|
233
|
+
const getExisting = vi.fn().mockResolvedValue(undefined);
|
|
234
|
+
const connect = vi.fn().mockResolvedValue(connection);
|
|
235
|
+
const storeTokens = vi.fn().mockResolvedValue(undefined);
|
|
236
|
+
const register = vi.fn();
|
|
237
|
+
const events: string[] = [];
|
|
238
|
+
let releaseFirstRegistration!: () => void;
|
|
239
|
+
const firstRegistrationReady = new Promise<void>((resolve) => {
|
|
240
|
+
releaseFirstRegistration = resolve;
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
first.jira.getAccessibleResources.mockResolvedValue(sites);
|
|
244
|
+
second.jira.getAccessibleResources.mockResolvedValue(sites);
|
|
245
|
+
first.jira.getMyself.mockResolvedValue({accountId: 'account-1'});
|
|
246
|
+
second.jira.getMyself.mockResolvedValue({accountId: 'account-2'});
|
|
247
|
+
first.jira.exchangeAuthorizationCode.mockResolvedValue({
|
|
248
|
+
accessToken: 'access-1',
|
|
249
|
+
refreshToken: 'refresh-1',
|
|
250
|
+
scopes: [],
|
|
251
|
+
});
|
|
252
|
+
second.jira.exchangeAuthorizationCode.mockResolvedValue({
|
|
253
|
+
accessToken: 'access-2',
|
|
254
|
+
refreshToken: 'refresh-2',
|
|
255
|
+
scopes: [],
|
|
256
|
+
});
|
|
257
|
+
getExisting.mockImplementation(() => {
|
|
258
|
+
events.push('get-existing');
|
|
259
|
+
return Promise.resolve(connect.mock.calls.length > 0 ? connection : undefined);
|
|
260
|
+
});
|
|
261
|
+
connect.mockImplementation((input) => {
|
|
262
|
+
events.push(`connect:${input.authorizingAccountId}`);
|
|
263
|
+
return Promise.resolve(connection);
|
|
264
|
+
});
|
|
265
|
+
storeTokens.mockImplementation(({accessToken}) => {
|
|
266
|
+
events.push(`store:${accessToken}`);
|
|
267
|
+
return Promise.resolve();
|
|
268
|
+
});
|
|
269
|
+
register.mockImplementation(async ({accessToken, onRegistrationSuccess}) => {
|
|
270
|
+
events.push(`register:${accessToken}`);
|
|
271
|
+
if (accessToken === 'access-1') await firstRegistrationReady;
|
|
272
|
+
await onRegistrationSuccess?.({});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
for (const params of [first, second]) {
|
|
276
|
+
params.getExistingJiraConnection = getExisting;
|
|
277
|
+
params.connectJiraInstallation = connect;
|
|
278
|
+
params.tokenStore.storeTokens = storeTokens;
|
|
279
|
+
params.registerJiraWebhook = register;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
first.withJiraInstallationLock = withJiraInstallationLock;
|
|
283
|
+
second.withJiraInstallationLock = withJiraInstallationLock;
|
|
284
|
+
|
|
285
|
+
const firstCallback = handleJiraCallback(first);
|
|
286
|
+
await vi.waitFor(() =>
|
|
287
|
+
expect(register).toHaveBeenCalledWith(expect.objectContaining({accessToken: 'access-1'})),
|
|
288
|
+
);
|
|
289
|
+
const secondCallback = handleJiraCallback(second);
|
|
290
|
+
await Promise.resolve();
|
|
291
|
+
expect(getExisting).toHaveBeenCalledTimes(1);
|
|
292
|
+
|
|
293
|
+
releaseFirstRegistration();
|
|
294
|
+
await expect(firstCallback).resolves.toMatchObject({id: 'connection-1'});
|
|
295
|
+
await expect(secondCallback).resolves.toMatchObject({id: 'connection-1'});
|
|
296
|
+
|
|
297
|
+
expect(events).toEqual([
|
|
298
|
+
'get-existing',
|
|
299
|
+
'connect:account-1',
|
|
300
|
+
'store:access-1',
|
|
301
|
+
'register:access-1',
|
|
302
|
+
'get-existing',
|
|
303
|
+
'store:access-2',
|
|
304
|
+
'connect:account-2',
|
|
305
|
+
'register:access-2',
|
|
306
|
+
]);
|
|
307
|
+
});
|
|
129
308
|
});
|
package/src/core/install.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {logger} from '@shipfox/node-opentelemetry';
|
|
|
4
4
|
import type {JiraAccessibleResource, JiraApiClient, JiraAuthorization} from '#api/client.js';
|
|
5
5
|
import type {JiraPendingSelectionStore} from '#core/pending.js';
|
|
6
6
|
import type {JiraTokenStore} from '#core/tokens.js';
|
|
7
|
+
import {type JiraInstallationLock, withJiraInstallationLock} from '#db/installations.js';
|
|
7
8
|
import {
|
|
8
9
|
JiraInstallationAlreadyLinkedError,
|
|
9
10
|
JiraInstallStateActorMismatchError,
|
|
@@ -46,6 +47,17 @@ export interface HandleJiraCallbackParams {
|
|
|
46
47
|
connectJiraInstallation(
|
|
47
48
|
input: ConnectJiraInstallationInput,
|
|
48
49
|
): Promise<IntegrationConnection<'jira'>>;
|
|
50
|
+
registerJiraWebhook(input: {
|
|
51
|
+
connectionId: string;
|
|
52
|
+
cloudId: string;
|
|
53
|
+
accessToken: string;
|
|
54
|
+
withRegistrationLock?: JiraInstallationLock;
|
|
55
|
+
onRegistrationSuccess: (input: {tx?: unknown}) => Promise<void>;
|
|
56
|
+
onRegistrationFailure: (input: {tx?: unknown}) => Promise<void>;
|
|
57
|
+
}): Promise<void>;
|
|
58
|
+
withJiraInstallationLock?: JiraInstallationLock;
|
|
59
|
+
markConnectionActive(input: {connectionId: string; tx?: unknown}): Promise<void>;
|
|
60
|
+
markConnectionError(input: {connectionId: string; tx?: unknown}): Promise<void>;
|
|
49
61
|
disconnectJiraInstallation(input: {connectionId: string}): Promise<void>;
|
|
50
62
|
}
|
|
51
63
|
|
|
@@ -136,23 +148,23 @@ async function verifyClaims(
|
|
|
136
148
|
return claims;
|
|
137
149
|
}
|
|
138
150
|
|
|
139
|
-
|
|
151
|
+
function resolveJiraSite(
|
|
140
152
|
params: Omit<HandleJiraCallbackParams, 'code'> & {
|
|
141
153
|
authorization: JiraAuthorization;
|
|
142
154
|
site: JiraAccessibleResource;
|
|
143
155
|
claims: {workspaceId: string; userId: string};
|
|
144
156
|
},
|
|
145
157
|
): Promise<IntegrationConnection<'jira'>> {
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
158
|
+
const withInstallationLock = params.withJiraInstallationLock ?? withJiraInstallationLock;
|
|
159
|
+
return withInstallationLock(params.site.cloudId, async () => {
|
|
160
|
+
const existing = await params.getExistingJiraConnection({cloudId: params.site.cloudId});
|
|
161
|
+
if (existing && existing.workspaceId !== params.claims.workspaceId)
|
|
162
|
+
throw new JiraInstallationAlreadyLinkedError(params.site.cloudId);
|
|
151
163
|
const identity = await params.jira.getMyself({
|
|
152
164
|
accessToken: params.authorization.accessToken,
|
|
153
165
|
cloudId: params.site.cloudId,
|
|
154
166
|
});
|
|
155
|
-
const
|
|
167
|
+
const installationInput = {
|
|
156
168
|
workspaceId: params.claims.workspaceId,
|
|
157
169
|
cloudId: params.site.cloudId,
|
|
158
170
|
siteUrl: params.site.url,
|
|
@@ -161,19 +173,52 @@ async function resolveJiraSite(
|
|
|
161
173
|
scopes: params.site.scopes,
|
|
162
174
|
tokenExpiresAt: params.authorization.expiresAt ?? null,
|
|
163
175
|
displayName: `Jira ${params.site.name}`,
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
176
|
+
};
|
|
177
|
+
let connection = existing;
|
|
178
|
+
try {
|
|
179
|
+
if (!connection) connection = await params.connectJiraInstallation(installationInput);
|
|
180
|
+
await params.tokenStore.storeTokens({
|
|
181
|
+
connectionId: connection.id,
|
|
182
|
+
accessToken: params.authorization.accessToken,
|
|
183
|
+
refreshToken: params.authorization.refreshToken,
|
|
184
|
+
editedBy: params.claims.userId,
|
|
185
|
+
});
|
|
186
|
+
if (existing) connection = await params.connectJiraInstallation(installationInput);
|
|
187
|
+
} catch (error) {
|
|
188
|
+
if (existing) {
|
|
189
|
+
const markedError = await bestEffortMarkConnectionError(params, existing.id);
|
|
190
|
+
if (!markedError) await bestEffortMarkConnectionError(params, existing.id);
|
|
191
|
+
} else if (connection) {
|
|
192
|
+
await bestEffortDisconnect(params, connection.id);
|
|
193
|
+
}
|
|
194
|
+
throw error;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
let registrationFailureHandled = false;
|
|
198
|
+
try {
|
|
199
|
+
await params.registerJiraWebhook({
|
|
200
|
+
connectionId: connection.id,
|
|
201
|
+
cloudId: params.site.cloudId,
|
|
202
|
+
accessToken: params.authorization.accessToken,
|
|
203
|
+
withRegistrationLock: async (_lockKey, fn) => fn(),
|
|
204
|
+
onRegistrationSuccess: async ({tx}) => {
|
|
205
|
+
await params.markConnectionActive(connectionLifecycleInput(connection.id, tx));
|
|
206
|
+
},
|
|
207
|
+
onRegistrationFailure: async ({tx}) => {
|
|
208
|
+
registrationFailureHandled = await bestEffortMarkConnectionError(
|
|
209
|
+
params,
|
|
210
|
+
connection.id,
|
|
211
|
+
tx,
|
|
212
|
+
);
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
} catch (error) {
|
|
216
|
+
if (!registrationFailureHandled) await bestEffortMarkConnectionError(params, connection.id);
|
|
217
|
+
throw error;
|
|
218
|
+
}
|
|
219
|
+
|
|
172
220
|
return connection;
|
|
173
|
-
}
|
|
174
|
-
if (connectionId && !existing) await bestEffortDisconnect(params, connectionId);
|
|
175
|
-
throw error;
|
|
176
|
-
}
|
|
221
|
+
});
|
|
177
222
|
}
|
|
178
223
|
|
|
179
224
|
async function bestEffortDisconnect(
|
|
@@ -189,3 +234,24 @@ async function bestEffortDisconnect(
|
|
|
189
234
|
);
|
|
190
235
|
}
|
|
191
236
|
}
|
|
237
|
+
|
|
238
|
+
async function bestEffortMarkConnectionError(
|
|
239
|
+
params: Pick<HandleJiraCallbackParams, 'markConnectionError'>,
|
|
240
|
+
connectionId: string,
|
|
241
|
+
tx?: unknown,
|
|
242
|
+
): Promise<boolean> {
|
|
243
|
+
try {
|
|
244
|
+
await params.markConnectionError(connectionLifecycleInput(connectionId, tx));
|
|
245
|
+
return true;
|
|
246
|
+
} catch (error) {
|
|
247
|
+
logger().warn(
|
|
248
|
+
{err: error, connectionId},
|
|
249
|
+
'Jira connection error-state update failed after webhook registration rejection',
|
|
250
|
+
);
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function connectionLifecycleInput(connectionId: string, tx?: unknown) {
|
|
256
|
+
return tx === undefined ? {connectionId} : {connectionId, tx};
|
|
257
|
+
}
|
|
@@ -30,6 +30,8 @@ function createStore() {
|
|
|
30
30
|
refreshAccessToken: vi.fn(),
|
|
31
31
|
getAccessibleResources: vi.fn(),
|
|
32
32
|
getMyself: vi.fn(),
|
|
33
|
+
registerDynamicWebhook: vi.fn(),
|
|
34
|
+
deleteDynamicWebhook: vi.fn(),
|
|
33
35
|
};
|
|
34
36
|
const markConnectionError = vi.fn().mockResolvedValue(undefined);
|
|
35
37
|
const connectionId = crypto.randomUUID();
|