@shipfox/api-integration-webhook 2.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 +2 -0
- package/.turbo/turbo-type$colon$emit.log +1 -0
- package/.turbo/turbo-type.log +1 -0
- package/CHANGELOG.md +50 -0
- package/LICENSE +21 -0
- package/dist/config.d.ts +4 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +9 -0
- package/dist/config.js.map +1 -0
- package/dist/constants.d.ts +4 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +18 -0
- package/dist/constants.js.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +29 -0
- package/dist/index.js.map +1 -0
- package/dist/presentation/dto/connections.d.ts +4 -0
- package/dist/presentation/dto/connections.d.ts.map +1 -0
- package/dist/presentation/dto/connections.js +15 -0
- package/dist/presentation/dto/connections.js.map +1 -0
- package/dist/presentation/routes/connections.d.ts +14 -0
- package/dist/presentation/routes/connections.d.ts.map +1 -0
- package/dist/presentation/routes/connections.js +153 -0
- package/dist/presentation/routes/connections.js.map +1 -0
- package/dist/presentation/routes/inbound.d.ts +11 -0
- package/dist/presentation/routes/inbound.d.ts.map +1 -0
- package/dist/presentation/routes/inbound.js +117 -0
- package/dist/presentation/routes/inbound.js.map +1 -0
- package/dist/tsconfig.test.tsbuildinfo +1 -0
- package/package.json +62 -0
- package/src/config.ts +8 -0
- package/src/constants.test.ts +23 -0
- package/src/constants.ts +21 -0
- package/src/index.ts +52 -0
- package/src/presentation/dto/connections.ts +20 -0
- package/src/presentation/routes/connections.test.ts +374 -0
- package/src/presentation/routes/connections.ts +184 -0
- package/src/presentation/routes/inbound.test.ts +210 -0
- package/src/presentation/routes/inbound.ts +132 -0
- package/test/env.ts +7 -0
- package/test/globalSetup.ts +5 -0
- package/test/setup.ts +6 -0
- package/tsconfig.build.json +9 -0
- package/tsconfig.build.tsbuildinfo +1 -0
- package/tsconfig.json +3 -0
- package/tsconfig.test.json +8 -0
- package/vitest.config.ts +12 -0
package/package.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@shipfox/api-integration-webhook",
|
|
3
|
+
"license": "MIT",
|
|
4
|
+
"version": "2.0.0",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/ShipfoxHQ/shipfox.git",
|
|
8
|
+
"directory": "libs/api/integration/webhook"
|
|
9
|
+
},
|
|
10
|
+
"private": false,
|
|
11
|
+
"type": "module",
|
|
12
|
+
"main": "dist/index.js",
|
|
13
|
+
"types": "dist/index.d.ts",
|
|
14
|
+
"imports": {
|
|
15
|
+
"#test/*": "./test/*",
|
|
16
|
+
"#*": {
|
|
17
|
+
"workspace-source": "./src/*",
|
|
18
|
+
"development": "./src/*",
|
|
19
|
+
"default": "./dist/*"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"exports": {
|
|
23
|
+
".": {
|
|
24
|
+
"development": {
|
|
25
|
+
"types": "./src/index.ts",
|
|
26
|
+
"default": "./src/index.ts"
|
|
27
|
+
},
|
|
28
|
+
"default": {
|
|
29
|
+
"types": "./dist/index.d.ts",
|
|
30
|
+
"default": "./dist/index.js"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@fastify/formbody": "^8.0.2",
|
|
36
|
+
"fastify-plugin": "^5.1.0",
|
|
37
|
+
"zod": "^4.4.3",
|
|
38
|
+
"@shipfox/api-auth-context": "2.0.0",
|
|
39
|
+
"@shipfox/api-integration-core-dto": "2.0.0",
|
|
40
|
+
"@shipfox/node-fastify": "0.2.1",
|
|
41
|
+
"@shipfox/config": "1.2.1",
|
|
42
|
+
"@shipfox/api-integration-webhook-dto": "2.0.0"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"fastify": "^5.3.3",
|
|
46
|
+
"fastify-type-provider-zod": "^6.0.0",
|
|
47
|
+
"@shipfox/biome": "1.8.1",
|
|
48
|
+
"@shipfox/ts-config": "1.3.8",
|
|
49
|
+
"@shipfox/vitest": "1.2.2",
|
|
50
|
+
"@shipfox/swc": "1.2.5",
|
|
51
|
+
"@shipfox/typescript": "1.1.6"
|
|
52
|
+
},
|
|
53
|
+
"scripts": {
|
|
54
|
+
"build": "shipfox-swc",
|
|
55
|
+
"check": "shipfox-biome-check",
|
|
56
|
+
"check:fix": "shipfox-biome-check --write",
|
|
57
|
+
"test": "shipfox-vitest-run",
|
|
58
|
+
"test:watch": "shipfox-vitest-watch",
|
|
59
|
+
"type": "shipfox-tsc-check",
|
|
60
|
+
"type:emit": "shipfox-tsc-emit"
|
|
61
|
+
}
|
|
62
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import {createConfig, url} from '@shipfox/config';
|
|
2
|
+
|
|
3
|
+
export const config = createConfig({
|
|
4
|
+
WEBHOOK_PUBLIC_URL: url({
|
|
5
|
+
desc: 'Public origin of the API used to build webhook inbound URLs returned to users. Set it to the externally reachable API URL, including the scheme.',
|
|
6
|
+
default: 'http://localhost:3000',
|
|
7
|
+
}),
|
|
8
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import {redactHeaders} from './constants.js';
|
|
2
|
+
|
|
3
|
+
describe('redactHeaders', () => {
|
|
4
|
+
it('keeps allowlisted headers and redacts every other value', () => {
|
|
5
|
+
const result = redactHeaders({
|
|
6
|
+
'content-type': 'application/json',
|
|
7
|
+
'user-agent': 'test-agent',
|
|
8
|
+
'x-delivery-id': 'delivery-1',
|
|
9
|
+
authorization: 'Bearer secret',
|
|
10
|
+
cookie: 'sid=secret',
|
|
11
|
+
'x-custom-secret': ['one', 'two'],
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
expect(result).toEqual({
|
|
15
|
+
'content-type': 'application/json',
|
|
16
|
+
'user-agent': 'test-agent',
|
|
17
|
+
'x-delivery-id': 'delivery-1',
|
|
18
|
+
authorization: '[redacted]',
|
|
19
|
+
cookie: '[redacted]',
|
|
20
|
+
'x-custom-secret': '[redacted]',
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
});
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export const WEBHOOK_INBOUND_BODY_LIMIT = 1 * 1024 * 1024;
|
|
2
|
+
|
|
3
|
+
export const WEBHOOK_FORWARDED_HEADERS = [
|
|
4
|
+
'content-type',
|
|
5
|
+
'user-agent',
|
|
6
|
+
'x-delivery-id',
|
|
7
|
+
'x-request-id',
|
|
8
|
+
] as const;
|
|
9
|
+
|
|
10
|
+
const forwardedHeaderSet = new Set<string>(WEBHOOK_FORWARDED_HEADERS);
|
|
11
|
+
|
|
12
|
+
export function redactHeaders(
|
|
13
|
+
headers: Record<string, string | string[] | undefined>,
|
|
14
|
+
): Record<string, string | string[]> {
|
|
15
|
+
const redacted: Record<string, string | string[]> = {};
|
|
16
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
17
|
+
if (value === undefined) continue;
|
|
18
|
+
redacted[name] = forwardedHeaderSet.has(name.toLowerCase()) ? value : '[redacted]';
|
|
19
|
+
}
|
|
20
|
+
return redacted;
|
|
21
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CreateIntegrationConnectionFn,
|
|
3
|
+
DeleteIntegrationConnectionFn,
|
|
4
|
+
GetIntegrationConnectionByIdFn,
|
|
5
|
+
IntegrationConnection,
|
|
6
|
+
IntegrationTx,
|
|
7
|
+
PublishIntegrationEventReceivedFn,
|
|
8
|
+
UpdateIntegrationConnectionLifecycleStatusFn,
|
|
9
|
+
} from '@shipfox/api-integration-core-dto';
|
|
10
|
+
import {WEBHOOK_PROVIDER} from '@shipfox/api-integration-webhook-dto';
|
|
11
|
+
import {config} from '#config.js';
|
|
12
|
+
import {createWebhookConnectionRoutes} from '#presentation/routes/connections.js';
|
|
13
|
+
import {createWebhookInboundRoutes} from '#presentation/routes/inbound.js';
|
|
14
|
+
|
|
15
|
+
export {redactHeaders, WEBHOOK_FORWARDED_HEADERS, WEBHOOK_INBOUND_BODY_LIMIT} from '#constants.js';
|
|
16
|
+
|
|
17
|
+
export interface CreateWebhookIntegrationProviderOptions {
|
|
18
|
+
coreDb: () => {
|
|
19
|
+
transaction<T>(callback: (tx: IntegrationTx) => Promise<T>): Promise<T>;
|
|
20
|
+
};
|
|
21
|
+
createIntegrationConnection: CreateIntegrationConnectionFn;
|
|
22
|
+
listIntegrationConnections: (params: {workspaceId: string}) => Promise<IntegrationConnection[]>;
|
|
23
|
+
getIntegrationConnectionById: GetIntegrationConnectionByIdFn;
|
|
24
|
+
updateIntegrationConnectionLifecycleStatus: UpdateIntegrationConnectionLifecycleStatusFn;
|
|
25
|
+
deleteIntegrationConnection: DeleteIntegrationConnectionFn;
|
|
26
|
+
publishIntegrationEventReceived: PublishIntegrationEventReceivedFn;
|
|
27
|
+
baseUrl?: string | undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function createWebhookIntegrationProvider(options: CreateWebhookIntegrationProviderOptions) {
|
|
31
|
+
const baseUrl = options.baseUrl ?? config.WEBHOOK_PUBLIC_URL;
|
|
32
|
+
return {
|
|
33
|
+
provider: WEBHOOK_PROVIDER,
|
|
34
|
+
displayName: 'Webhook',
|
|
35
|
+
routes: [
|
|
36
|
+
createWebhookConnectionRoutes({
|
|
37
|
+
baseUrl,
|
|
38
|
+
createIntegrationConnection: options.createIntegrationConnection,
|
|
39
|
+
listIntegrationConnections: options.listIntegrationConnections,
|
|
40
|
+
getIntegrationConnectionById: options.getIntegrationConnectionById,
|
|
41
|
+
updateIntegrationConnectionLifecycleStatus:
|
|
42
|
+
options.updateIntegrationConnectionLifecycleStatus,
|
|
43
|
+
deleteIntegrationConnection: options.deleteIntegrationConnection,
|
|
44
|
+
}),
|
|
45
|
+
createWebhookInboundRoutes({
|
|
46
|
+
coreDb: options.coreDb,
|
|
47
|
+
getIntegrationConnectionById: options.getIntegrationConnectionById,
|
|
48
|
+
publishIntegrationEventReceived: options.publishIntegrationEventReceived,
|
|
49
|
+
}),
|
|
50
|
+
],
|
|
51
|
+
};
|
|
52
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type {IntegrationConnection} from '@shipfox/api-integration-core-dto';
|
|
2
|
+
import type {WebhookConnectionDto} from '@shipfox/api-integration-webhook-dto';
|
|
3
|
+
|
|
4
|
+
const TRAILING_SLASHES_RE = /\/+$/;
|
|
5
|
+
|
|
6
|
+
export function toWebhookConnectionDto(
|
|
7
|
+
connection: IntegrationConnection,
|
|
8
|
+
baseUrl: string,
|
|
9
|
+
): WebhookConnectionDto {
|
|
10
|
+
return {
|
|
11
|
+
id: connection.id,
|
|
12
|
+
workspace_id: connection.workspaceId,
|
|
13
|
+
name: connection.displayName,
|
|
14
|
+
slug: connection.slug,
|
|
15
|
+
lifecycle_status: connection.lifecycleStatus,
|
|
16
|
+
inbound_url: `${baseUrl.replace(TRAILING_SLASHES_RE, '')}/webhook/${connection.id}`,
|
|
17
|
+
created_at: connection.createdAt.toISOString(),
|
|
18
|
+
updated_at: connection.updatedAt.toISOString(),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AUTH_USER,
|
|
3
|
+
buildUserContext,
|
|
4
|
+
setUserContext,
|
|
5
|
+
type UserContextMembership,
|
|
6
|
+
} from '@shipfox/api-auth-context';
|
|
7
|
+
import {
|
|
8
|
+
ConnectionSlugConflictError,
|
|
9
|
+
type IntegrationConnection,
|
|
10
|
+
} from '@shipfox/api-integration-core-dto';
|
|
11
|
+
import {WEBHOOK_RESERVED_SLUGS} from '@shipfox/api-integration-webhook-dto';
|
|
12
|
+
import {type AuthMethod, ClientError, closeApp, createApp} from '@shipfox/node-fastify';
|
|
13
|
+
import type {FastifyInstance, FastifyRequest} from 'fastify';
|
|
14
|
+
import type {CreateWebhookIntegrationProviderOptions} from '#index.js';
|
|
15
|
+
import {createWebhookIntegrationProvider} from '#index.js';
|
|
16
|
+
|
|
17
|
+
type CreateConnectionFn = CreateWebhookIntegrationProviderOptions['createIntegrationConnection'];
|
|
18
|
+
type ListConnectionsFn = CreateWebhookIntegrationProviderOptions['listIntegrationConnections'];
|
|
19
|
+
type GetConnectionFn = CreateWebhookIntegrationProviderOptions['getIntegrationConnectionById'];
|
|
20
|
+
type UpdateConnectionFn =
|
|
21
|
+
CreateWebhookIntegrationProviderOptions['updateIntegrationConnectionLifecycleStatus'];
|
|
22
|
+
type DeleteConnectionFn = CreateWebhookIntegrationProviderOptions['deleteIntegrationConnection'];
|
|
23
|
+
|
|
24
|
+
const INBOUND_URL_RE = /^https:\/\/api\.example\.com\/webhook\//;
|
|
25
|
+
|
|
26
|
+
let authenticatedMemberships: UserContextMembership[] = [];
|
|
27
|
+
|
|
28
|
+
function authorizeWorkspace(workspaceId: string): void {
|
|
29
|
+
authenticatedMemberships = [{workspaceId, role: 'admin'}];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const fakeUserAuth: AuthMethod = {
|
|
33
|
+
name: AUTH_USER,
|
|
34
|
+
authenticate: (request: FastifyRequest) => {
|
|
35
|
+
if (request.headers.authorization !== 'Bearer user') {
|
|
36
|
+
throw new ClientError('Invalid user token', 'unauthorized', {status: 401});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
setUserContext(
|
|
40
|
+
request,
|
|
41
|
+
buildUserContext({
|
|
42
|
+
userId: 'user-1',
|
|
43
|
+
email: 'user@example.com',
|
|
44
|
+
memberships: authenticatedMemberships,
|
|
45
|
+
}),
|
|
46
|
+
);
|
|
47
|
+
return Promise.resolve();
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
function duplicateSlugError(): Error {
|
|
52
|
+
const error = new Error('duplicate connection');
|
|
53
|
+
error.name = 'IntegrationConnectionAlreadyExistsError';
|
|
54
|
+
return error;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function connectionSlugConflictError(): Error {
|
|
58
|
+
return new ConnectionSlugConflictError(new Error('duplicate slug'));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function fakeConnection(overrides: Partial<IntegrationConnection> = {}): IntegrationConnection {
|
|
62
|
+
const now = new Date();
|
|
63
|
+
return {
|
|
64
|
+
id: crypto.randomUUID(),
|
|
65
|
+
workspaceId: crypto.randomUUID(),
|
|
66
|
+
provider: 'webhook',
|
|
67
|
+
externalAccountId: 'stripe',
|
|
68
|
+
slug: 'stripe',
|
|
69
|
+
displayName: 'Stripe',
|
|
70
|
+
lifecycleStatus: 'active',
|
|
71
|
+
createdAt: now,
|
|
72
|
+
updatedAt: now,
|
|
73
|
+
...overrides,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function createStore() {
|
|
78
|
+
const byId = new Map<string, IntegrationConnection>();
|
|
79
|
+
const bySlug = new Map<string, string>();
|
|
80
|
+
const seed = (connection: IntegrationConnection) => {
|
|
81
|
+
byId.set(connection.id, connection);
|
|
82
|
+
bySlug.set(`${connection.workspaceId}:${connection.slug}`, connection.id);
|
|
83
|
+
return connection;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
seed,
|
|
88
|
+
createIntegrationConnection: vi.fn<CreateConnectionFn>((input) => {
|
|
89
|
+
const key = `${input.workspaceId}:${input.slug}`;
|
|
90
|
+
if (bySlug.has(key)) throw duplicateSlugError();
|
|
91
|
+
const connection = fakeConnection({
|
|
92
|
+
workspaceId: input.workspaceId,
|
|
93
|
+
provider: input.provider,
|
|
94
|
+
externalAccountId: input.externalAccountId,
|
|
95
|
+
slug: input.slug,
|
|
96
|
+
displayName: input.displayName,
|
|
97
|
+
lifecycleStatus: input.lifecycleStatus ?? 'active',
|
|
98
|
+
});
|
|
99
|
+
seed(connection);
|
|
100
|
+
return Promise.resolve(connection);
|
|
101
|
+
}),
|
|
102
|
+
listIntegrationConnections: vi.fn<ListConnectionsFn>(({workspaceId}) =>
|
|
103
|
+
Promise.resolve(
|
|
104
|
+
[...byId.values()].filter((connection) => connection.workspaceId === workspaceId),
|
|
105
|
+
),
|
|
106
|
+
),
|
|
107
|
+
getIntegrationConnectionById: vi.fn<GetConnectionFn>((id) => Promise.resolve(byId.get(id))),
|
|
108
|
+
updateIntegrationConnectionLifecycleStatus: vi.fn<UpdateConnectionFn>(
|
|
109
|
+
({id, lifecycleStatus}) => {
|
|
110
|
+
const connection = byId.get(id);
|
|
111
|
+
if (!connection) return Promise.resolve(undefined);
|
|
112
|
+
const updated = {...connection, lifecycleStatus, updatedAt: new Date()};
|
|
113
|
+
byId.set(id, updated);
|
|
114
|
+
return Promise.resolve(updated);
|
|
115
|
+
},
|
|
116
|
+
),
|
|
117
|
+
deleteIntegrationConnection: vi.fn<DeleteConnectionFn>(({id}) =>
|
|
118
|
+
Promise.resolve(byId.delete(id)),
|
|
119
|
+
),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function createTestApp(store = createStore()): Promise<{
|
|
124
|
+
app: FastifyInstance;
|
|
125
|
+
store: ReturnType<typeof createStore>;
|
|
126
|
+
}> {
|
|
127
|
+
const provider = createWebhookIntegrationProvider({
|
|
128
|
+
baseUrl: 'https://api.example.com/',
|
|
129
|
+
coreDb: () => ({transaction: (callback) => callback({})}),
|
|
130
|
+
publishIntegrationEventReceived: vi.fn(() => Promise.resolve({published: true})),
|
|
131
|
+
...store,
|
|
132
|
+
} satisfies CreateWebhookIntegrationProviderOptions);
|
|
133
|
+
const app = await createApp({
|
|
134
|
+
auth: [fakeUserAuth],
|
|
135
|
+
routes: provider.routes,
|
|
136
|
+
swagger: false,
|
|
137
|
+
});
|
|
138
|
+
await app.ready();
|
|
139
|
+
return {app, store};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
describe('webhook connection routes', () => {
|
|
143
|
+
beforeEach(async () => {
|
|
144
|
+
authenticatedMemberships = [];
|
|
145
|
+
await closeApp();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
afterEach(async () => {
|
|
149
|
+
await closeApp();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('creates a webhook connection with its inbound URL', async () => {
|
|
153
|
+
const workspaceId = crypto.randomUUID();
|
|
154
|
+
authorizeWorkspace(workspaceId);
|
|
155
|
+
const {app} = await createTestApp();
|
|
156
|
+
|
|
157
|
+
const res = await app.inject({
|
|
158
|
+
method: 'POST',
|
|
159
|
+
url: '/integrations/webhook/connections',
|
|
160
|
+
headers: {authorization: 'Bearer user'},
|
|
161
|
+
payload: {workspace_id: workspaceId, name: 'Stripe', slug: 'stripe-prod'},
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
expect(res.statusCode).toBe(201);
|
|
165
|
+
expect(res.json()).toMatchObject({
|
|
166
|
+
workspace_id: workspaceId,
|
|
167
|
+
name: 'Stripe',
|
|
168
|
+
slug: 'stripe-prod',
|
|
169
|
+
lifecycle_status: 'active',
|
|
170
|
+
inbound_url: expect.stringMatching(INBOUND_URL_RE),
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('returns 409 for a duplicate slug in the workspace', async () => {
|
|
175
|
+
const workspaceId = crypto.randomUUID();
|
|
176
|
+
authorizeWorkspace(workspaceId);
|
|
177
|
+
const {app} = await createTestApp();
|
|
178
|
+
const payload = {workspace_id: workspaceId, name: 'Stripe', slug: 'stripe-prod'};
|
|
179
|
+
|
|
180
|
+
const first = await app.inject({
|
|
181
|
+
method: 'POST',
|
|
182
|
+
url: '/integrations/webhook/connections',
|
|
183
|
+
headers: {authorization: 'Bearer user'},
|
|
184
|
+
payload,
|
|
185
|
+
});
|
|
186
|
+
const second = await app.inject({
|
|
187
|
+
method: 'POST',
|
|
188
|
+
url: '/integrations/webhook/connections',
|
|
189
|
+
headers: {authorization: 'Bearer user'},
|
|
190
|
+
payload,
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
expect(first.statusCode).toBe(201);
|
|
194
|
+
expect(second.statusCode).toBe(409);
|
|
195
|
+
expect(second.json().code).toBe('slug-already-exists');
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('returns 409 when the core connection create reports a slug conflict', async () => {
|
|
199
|
+
const workspaceId = crypto.randomUUID();
|
|
200
|
+
authorizeWorkspace(workspaceId);
|
|
201
|
+
const store = createStore();
|
|
202
|
+
store.createIntegrationConnection.mockRejectedValueOnce(connectionSlugConflictError());
|
|
203
|
+
const {app} = await createTestApp(store);
|
|
204
|
+
|
|
205
|
+
const res = await app.inject({
|
|
206
|
+
method: 'POST',
|
|
207
|
+
url: '/integrations/webhook/connections',
|
|
208
|
+
headers: {authorization: 'Bearer user'},
|
|
209
|
+
payload: {workspace_id: workspaceId, name: 'Stripe', slug: 'stripe-prod'},
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
expect(res.statusCode).toBe(409);
|
|
213
|
+
expect(res.json().code).toBe('slug-already-exists');
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('lists only webhook connections for the workspace', async () => {
|
|
217
|
+
const workspaceId = crypto.randomUUID();
|
|
218
|
+
authorizeWorkspace(workspaceId);
|
|
219
|
+
const store = createStore();
|
|
220
|
+
const webhook = store.seed(fakeConnection({workspaceId, externalAccountId: 'stripe'}));
|
|
221
|
+
store.seed(fakeConnection({workspaceId, provider: 'github', externalAccountId: 'gh'}));
|
|
222
|
+
store.seed(fakeConnection({workspaceId: crypto.randomUUID(), externalAccountId: 'other'}));
|
|
223
|
+
const {app} = await createTestApp(store);
|
|
224
|
+
|
|
225
|
+
const res = await app.inject({
|
|
226
|
+
method: 'GET',
|
|
227
|
+
url: `/integrations/webhook/connections?workspace_id=${workspaceId}`,
|
|
228
|
+
headers: {authorization: 'Bearer user'},
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
expect(res.statusCode).toBe(200);
|
|
232
|
+
expect(res.json().connections).toEqual([
|
|
233
|
+
expect.objectContaining({id: webhook.id, slug: 'stripe'}),
|
|
234
|
+
]);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('updates a webhook connection lifecycle status', async () => {
|
|
238
|
+
const store = createStore();
|
|
239
|
+
const connection = store.seed(fakeConnection());
|
|
240
|
+
authorizeWorkspace(connection.workspaceId);
|
|
241
|
+
const {app} = await createTestApp(store);
|
|
242
|
+
|
|
243
|
+
const res = await app.inject({
|
|
244
|
+
method: 'PATCH',
|
|
245
|
+
url: `/integrations/webhook/connections/${connection.id}`,
|
|
246
|
+
headers: {authorization: 'Bearer user'},
|
|
247
|
+
payload: {lifecycle_status: 'disabled'},
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
expect(res.statusCode).toBe(200);
|
|
251
|
+
expect(res.json().lifecycle_status).toBe('disabled');
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it.each([
|
|
255
|
+
{description: 'missing connection', connectionId: crypto.randomUUID()},
|
|
256
|
+
{
|
|
257
|
+
description: 'non-webhook connection',
|
|
258
|
+
connectionId: undefined,
|
|
259
|
+
seed: (store: ReturnType<typeof createStore>) =>
|
|
260
|
+
store.seed(fakeConnection({provider: 'github'})).id,
|
|
261
|
+
},
|
|
262
|
+
])('returns 404 when patching a $description', async ({connectionId, seed}) => {
|
|
263
|
+
const store = createStore();
|
|
264
|
+
const id = seed ? seed(store) : connectionId;
|
|
265
|
+
if (seed && id) {
|
|
266
|
+
const connection = await store.getIntegrationConnectionById(id);
|
|
267
|
+
if (connection) authorizeWorkspace(connection.workspaceId);
|
|
268
|
+
}
|
|
269
|
+
const {app} = await createTestApp(store);
|
|
270
|
+
|
|
271
|
+
const res = await app.inject({
|
|
272
|
+
method: 'PATCH',
|
|
273
|
+
url: `/integrations/webhook/connections/${id}`,
|
|
274
|
+
headers: {authorization: 'Bearer user'},
|
|
275
|
+
payload: {lifecycle_status: 'disabled'},
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
expect(res.statusCode).toBe(404);
|
|
279
|
+
expect(res.json().code).toBe('not-found');
|
|
280
|
+
expect(store.updateIntegrationConnectionLifecycleStatus).not.toHaveBeenCalled();
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('returns 404 when a webhook connection disappears during patch', async () => {
|
|
284
|
+
const store = createStore();
|
|
285
|
+
const connection = store.seed(fakeConnection());
|
|
286
|
+
authorizeWorkspace(connection.workspaceId);
|
|
287
|
+
store.updateIntegrationConnectionLifecycleStatus.mockResolvedValueOnce(undefined);
|
|
288
|
+
const {app} = await createTestApp(store);
|
|
289
|
+
|
|
290
|
+
const res = await app.inject({
|
|
291
|
+
method: 'PATCH',
|
|
292
|
+
url: `/integrations/webhook/connections/${connection.id}`,
|
|
293
|
+
headers: {authorization: 'Bearer user'},
|
|
294
|
+
payload: {lifecycle_status: 'disabled'},
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
expect(res.statusCode).toBe(404);
|
|
298
|
+
expect(res.json().code).toBe('not-found');
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('deletes a webhook connection', async () => {
|
|
302
|
+
const store = createStore();
|
|
303
|
+
const connection = store.seed(fakeConnection());
|
|
304
|
+
authorizeWorkspace(connection.workspaceId);
|
|
305
|
+
const {app} = await createTestApp(store);
|
|
306
|
+
|
|
307
|
+
const res = await app.inject({
|
|
308
|
+
method: 'DELETE',
|
|
309
|
+
url: `/integrations/webhook/connections/${connection.id}`,
|
|
310
|
+
headers: {authorization: 'Bearer user'},
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
expect(res.statusCode).toBe(204);
|
|
314
|
+
expect(store.deleteIntegrationConnection).toHaveBeenCalledWith({id: connection.id});
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it.each([
|
|
318
|
+
{description: 'missing connection', connectionId: crypto.randomUUID()},
|
|
319
|
+
{
|
|
320
|
+
description: 'non-webhook connection',
|
|
321
|
+
connectionId: undefined,
|
|
322
|
+
seed: (store: ReturnType<typeof createStore>) =>
|
|
323
|
+
store.seed(fakeConnection({provider: 'github'})).id,
|
|
324
|
+
},
|
|
325
|
+
])('returns 404 when deleting a $description', async ({connectionId, seed}) => {
|
|
326
|
+
const store = createStore();
|
|
327
|
+
const id = seed ? seed(store) : connectionId;
|
|
328
|
+
if (seed && id) {
|
|
329
|
+
const connection = await store.getIntegrationConnectionById(id);
|
|
330
|
+
if (connection) authorizeWorkspace(connection.workspaceId);
|
|
331
|
+
}
|
|
332
|
+
const {app} = await createTestApp(store);
|
|
333
|
+
|
|
334
|
+
const res = await app.inject({
|
|
335
|
+
method: 'DELETE',
|
|
336
|
+
url: `/integrations/webhook/connections/${id}`,
|
|
337
|
+
headers: {authorization: 'Bearer user'},
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
expect(res.statusCode).toBe(404);
|
|
341
|
+
expect(res.json().code).toBe('not-found');
|
|
342
|
+
expect(store.deleteIntegrationConnection).not.toHaveBeenCalled();
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it.each(WEBHOOK_RESERVED_SLUGS)('rejects reserved slug %s', async (slug) => {
|
|
346
|
+
const workspaceId = crypto.randomUUID();
|
|
347
|
+
authorizeWorkspace(workspaceId);
|
|
348
|
+
const {app} = await createTestApp();
|
|
349
|
+
|
|
350
|
+
const res = await app.inject({
|
|
351
|
+
method: 'POST',
|
|
352
|
+
url: '/integrations/webhook/connections',
|
|
353
|
+
headers: {authorization: 'Bearer user'},
|
|
354
|
+
payload: {workspace_id: workspaceId, name: 'Reserved', slug},
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
expect(res.statusCode).toBe(400);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it('rejects a malformed slug', async () => {
|
|
361
|
+
const workspaceId = crypto.randomUUID();
|
|
362
|
+
authorizeWorkspace(workspaceId);
|
|
363
|
+
const {app} = await createTestApp();
|
|
364
|
+
|
|
365
|
+
const res = await app.inject({
|
|
366
|
+
method: 'POST',
|
|
367
|
+
url: '/integrations/webhook/connections',
|
|
368
|
+
headers: {authorization: 'Bearer user'},
|
|
369
|
+
payload: {workspace_id: workspaceId, name: 'Malformed', slug: 'Stripe_Prod'},
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
expect(res.statusCode).toBe(400);
|
|
373
|
+
});
|
|
374
|
+
});
|