@shipfox/api-integration-slack 4.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.
Files changed (158) hide show
  1. package/.turbo/turbo-build.log +2 -0
  2. package/.turbo/turbo-type$colon$emit.log +1 -0
  3. package/.turbo/turbo-type.log +1 -0
  4. package/CHANGELOG.md +22 -0
  5. package/LICENSE +21 -0
  6. package/dist/api/client.d.ts +28 -0
  7. package/dist/api/client.d.ts.map +1 -0
  8. package/dist/api/client.js +163 -0
  9. package/dist/api/client.js.map +1 -0
  10. package/dist/config.d.ts +8 -0
  11. package/dist/config.d.ts.map +1 -0
  12. package/dist/config.js +21 -0
  13. package/dist/config.js.map +1 -0
  14. package/dist/core/agent-tools-provider.d.ts +75 -0
  15. package/dist/core/agent-tools-provider.d.ts.map +1 -0
  16. package/dist/core/agent-tools-provider.js +111 -0
  17. package/dist/core/agent-tools-provider.js.map +1 -0
  18. package/dist/core/agent-tools.d.ts +65 -0
  19. package/dist/core/agent-tools.d.ts.map +1 -0
  20. package/dist/core/agent-tools.js +177 -0
  21. package/dist/core/agent-tools.js.map +1 -0
  22. package/dist/core/disconnect.d.ts +18 -0
  23. package/dist/core/disconnect.d.ts.map +1 -0
  24. package/dist/core/disconnect.js +23 -0
  25. package/dist/core/disconnect.js.map +1 -0
  26. package/dist/core/errors.d.ts +44 -0
  27. package/dist/core/errors.d.ts.map +1 -0
  28. package/dist/core/errors.js +71 -0
  29. package/dist/core/errors.js.map +1 -0
  30. package/dist/core/install.d.ts +48 -0
  31. package/dist/core/install.d.ts.map +1 -0
  32. package/dist/core/install.js +88 -0
  33. package/dist/core/install.js.map +1 -0
  34. package/dist/core/scopes.d.ts +4 -0
  35. package/dist/core/scopes.d.ts.map +1 -0
  36. package/dist/core/scopes.js +24 -0
  37. package/dist/core/scopes.js.map +1 -0
  38. package/dist/core/signature.d.ts +10 -0
  39. package/dist/core/signature.d.ts.map +1 -0
  40. package/dist/core/signature.js +18 -0
  41. package/dist/core/signature.js.map +1 -0
  42. package/dist/core/state.d.ts +12 -0
  43. package/dist/core/state.d.ts.map +1 -0
  44. package/dist/core/state.js +54 -0
  45. package/dist/core/state.js.map +1 -0
  46. package/dist/core/tokens.d.ts +35 -0
  47. package/dist/core/tokens.d.ts.map +1 -0
  48. package/dist/core/tokens.js +51 -0
  49. package/dist/core/tokens.js.map +1 -0
  50. package/dist/core/webhook.d.ts +25 -0
  51. package/dist/core/webhook.d.ts.map +1 -0
  52. package/dist/core/webhook.js +160 -0
  53. package/dist/core/webhook.js.map +1 -0
  54. package/dist/db/db.d.ts +403 -0
  55. package/dist/db/db.d.ts.map +1 -0
  56. package/dist/db/db.js +18 -0
  57. package/dist/db/db.js.map +1 -0
  58. package/dist/db/installations.d.ts +40 -0
  59. package/dist/db/installations.d.ts.map +1 -0
  60. package/dist/db/installations.js +73 -0
  61. package/dist/db/installations.js.map +1 -0
  62. package/dist/db/migrations.d.ts +2 -0
  63. package/dist/db/migrations.d.ts.map +1 -0
  64. package/dist/db/migrations.js +5 -0
  65. package/dist/db/migrations.js.map +1 -0
  66. package/dist/db/schema/common.d.ts +2 -0
  67. package/dist/db/schema/common.d.ts.map +1 -0
  68. package/dist/db/schema/common.js +4 -0
  69. package/dist/db/schema/common.js.map +1 -0
  70. package/dist/db/schema/installations.d.ts +203 -0
  71. package/dist/db/schema/installations.d.ts.map +1 -0
  72. package/dist/db/schema/installations.js +42 -0
  73. package/dist/db/schema/installations.js.map +1 -0
  74. package/dist/index.d.ts +59 -0
  75. package/dist/index.d.ts.map +1 -0
  76. package/dist/index.js +73 -0
  77. package/dist/index.js.map +1 -0
  78. package/dist/presentation/dto/integrations.d.ts +2 -0
  79. package/dist/presentation/dto/integrations.d.ts.map +1 -0
  80. package/dist/presentation/dto/integrations.js +3 -0
  81. package/dist/presentation/dto/integrations.js.map +1 -0
  82. package/dist/presentation/e2eRoutes/create-connection.d.ts +16 -0
  83. package/dist/presentation/e2eRoutes/create-connection.d.ts.map +1 -0
  84. package/dist/presentation/e2eRoutes/create-connection.js +65 -0
  85. package/dist/presentation/e2eRoutes/create-connection.js.map +1 -0
  86. package/dist/presentation/e2eRoutes/index.d.ts +5 -0
  87. package/dist/presentation/e2eRoutes/index.d.ts.map +1 -0
  88. package/dist/presentation/e2eRoutes/index.js +11 -0
  89. package/dist/presentation/e2eRoutes/index.js.map +1 -0
  90. package/dist/presentation/routes/errors.d.ts +2 -0
  91. package/dist/presentation/routes/errors.d.ts.map +1 -0
  92. package/dist/presentation/routes/errors.js +75 -0
  93. package/dist/presentation/routes/errors.js.map +1 -0
  94. package/dist/presentation/routes/install.d.ts +19 -0
  95. package/dist/presentation/routes/install.d.ts.map +1 -0
  96. package/dist/presentation/routes/install.js +98 -0
  97. package/dist/presentation/routes/install.js.map +1 -0
  98. package/dist/presentation/routes/webhooks.d.ts +16 -0
  99. package/dist/presentation/routes/webhooks.d.ts.map +1 -0
  100. package/dist/presentation/routes/webhooks.js +166 -0
  101. package/dist/presentation/routes/webhooks.js.map +1 -0
  102. package/dist/tsconfig.test.tsbuildinfo +1 -0
  103. package/drizzle/0000_calm_fantastic_four.sql +16 -0
  104. package/drizzle/meta/0000_snapshot.json +132 -0
  105. package/drizzle/meta/_journal.json +13 -0
  106. package/drizzle.config.ts +7 -0
  107. package/package.json +71 -0
  108. package/src/api/client.test.ts +239 -0
  109. package/src/api/client.ts +243 -0
  110. package/src/config.test.ts +32 -0
  111. package/src/config.ts +20 -0
  112. package/src/connection-external-url.test.ts +25 -0
  113. package/src/core/agent-tools-provider.test.ts +256 -0
  114. package/src/core/agent-tools-provider.ts +147 -0
  115. package/src/core/agent-tools.test.ts +90 -0
  116. package/src/core/agent-tools.ts +189 -0
  117. package/src/core/disconnect.ts +26 -0
  118. package/src/core/errors.ts +92 -0
  119. package/src/core/install.test.ts +155 -0
  120. package/src/core/install.ts +137 -0
  121. package/src/core/scopes.test.ts +22 -0
  122. package/src/core/scopes.ts +25 -0
  123. package/src/core/signature.test.ts +45 -0
  124. package/src/core/signature.ts +41 -0
  125. package/src/core/state.test.ts +48 -0
  126. package/src/core/state.ts +82 -0
  127. package/src/core/tokens.test.ts +163 -0
  128. package/src/core/tokens.ts +98 -0
  129. package/src/core/webhook.test.ts +269 -0
  130. package/src/core/webhook.ts +223 -0
  131. package/src/db/db.ts +16 -0
  132. package/src/db/installations.test.ts +93 -0
  133. package/src/db/installations.ts +141 -0
  134. package/src/db/migrations.ts +4 -0
  135. package/src/db/schema/common.ts +3 -0
  136. package/src/db/schema/installations.ts +44 -0
  137. package/src/index.test.ts +25 -0
  138. package/src/index.ts +196 -0
  139. package/src/presentation/dto/integrations.ts +1 -0
  140. package/src/presentation/e2eRoutes/create-connection.ts +82 -0
  141. package/src/presentation/e2eRoutes/index.test.ts +96 -0
  142. package/src/presentation/e2eRoutes/index.ts +11 -0
  143. package/src/presentation/routes/errors.ts +68 -0
  144. package/src/presentation/routes/install.test.ts +229 -0
  145. package/src/presentation/routes/install.ts +117 -0
  146. package/src/presentation/routes/webhooks.test.ts +266 -0
  147. package/src/presentation/routes/webhooks.ts +170 -0
  148. package/test/api-secrets.d.ts +26 -0
  149. package/test/env.ts +13 -0
  150. package/test/factories/index.ts +1 -0
  151. package/test/globalSetup.ts +23 -0
  152. package/test/index.ts +1 -0
  153. package/test/setup.ts +17 -0
  154. package/tsconfig.build.json +9 -0
  155. package/tsconfig.build.tsbuildinfo +1 -0
  156. package/tsconfig.json +3 -0
  157. package/tsconfig.test.json +9 -0
  158. package/vitest.config.ts +12 -0
@@ -0,0 +1,229 @@
1
+ import {
2
+ AUTH_USER,
3
+ buildUserContext,
4
+ setUserContext,
5
+ type UserContextMembership,
6
+ } from '@shipfox/api-auth-context';
7
+ import type {IntegrationConnection} from '@shipfox/api-integration-core-dto';
8
+ import {type AuthMethod, ClientError, closeApp, createApp} from '@shipfox/node-fastify';
9
+ import type {FastifyInstance, FastifyRequest} from 'fastify';
10
+ import type {SlackApiClient} from '#api/client.js';
11
+ import {SlackTokenRotationUnsupportedError} from '#core/errors.js';
12
+ import type {ConnectSlackInstallationInput} from '#core/install.js';
13
+ import {verifySlackInstallState} from '#core/state.js';
14
+ import type {SlackTokenStore} from '#core/tokens.js';
15
+ import {createSlackIntegrationProvider} from '#index.js';
16
+
17
+ vi.mock('@shipfox/api-workspaces', () => ({
18
+ requireWorkspaceMembership: vi.fn(() => Promise.resolve()),
19
+ }));
20
+
21
+ let authenticatedMemberships: UserContextMembership[] = [];
22
+
23
+ const fakeUserAuth: AuthMethod = {
24
+ name: AUTH_USER,
25
+ authenticate: (request: FastifyRequest) => {
26
+ if (request.headers.authorization !== 'Bearer user') {
27
+ throw new ClientError('Invalid user token', 'unauthorized', {status: 401});
28
+ }
29
+ setUserContext(
30
+ request,
31
+ buildUserContext({
32
+ userId: 'user-1',
33
+ email: 'user@example.com',
34
+ memberships: authenticatedMemberships,
35
+ }),
36
+ );
37
+ return Promise.resolve();
38
+ },
39
+ };
40
+
41
+ function slackClient(overrides: Partial<SlackApiClient> = {}): SlackApiClient {
42
+ return {
43
+ exchangeAuthorizationCode: vi.fn(() =>
44
+ Promise.resolve({
45
+ accessToken: 'xoxb-token',
46
+ botUserId: 'U123',
47
+ appId: 'A123',
48
+ teamId: 'T123',
49
+ teamName: 'Acme',
50
+ scopes: [
51
+ 'app_mentions:read',
52
+ 'im:history',
53
+ 'chat:write',
54
+ 'channels:history',
55
+ 'groups:history',
56
+ 'channels:read',
57
+ 'groups:read',
58
+ 'users:read',
59
+ 'reactions:read',
60
+ 'reactions:write',
61
+ 'commands',
62
+ ],
63
+ }),
64
+ ),
65
+ revokeToken: vi.fn(() => Promise.resolve()),
66
+ callMethod: vi.fn(() => Promise.resolve({ok: true})),
67
+ ...overrides,
68
+ };
69
+ }
70
+
71
+ function connection(input: Partial<IntegrationConnection<'slack'>> = {}) {
72
+ return {
73
+ id: '00000000-0000-4000-8000-000000000001',
74
+ workspaceId: '00000000-0000-4000-8000-000000000002',
75
+ provider: 'slack',
76
+ externalAccountId: 'T123',
77
+ slug: 'slack_acme',
78
+ displayName: 'Slack Acme',
79
+ lifecycleStatus: 'active',
80
+ createdAt: new Date(),
81
+ updatedAt: new Date(),
82
+ ...input,
83
+ } satisfies IntegrationConnection<'slack'>;
84
+ }
85
+
86
+ async function createTestApp(
87
+ options: {
88
+ slack?: SlackApiClient | undefined;
89
+ tokenStore?: Pick<SlackTokenStore, 'storeTokens'> | undefined;
90
+ agentTools?: {tokenStore: Pick<SlackTokenStore, 'getAccessToken'>} | undefined;
91
+ connectSlackInstallation?:
92
+ | ((input: ConnectSlackInstallationInput) => Promise<IntegrationConnection<'slack'>>)
93
+ | undefined;
94
+ } = {},
95
+ ): Promise<FastifyInstance> {
96
+ const provider = createSlackIntegrationProvider({
97
+ slack: options.slack ?? slackClient(),
98
+ agentTools: options.agentTools,
99
+ routes: {
100
+ tokenStore: options.tokenStore ?? {storeTokens: vi.fn(() => Promise.resolve())},
101
+ getExistingSlackConnection: vi.fn(() => Promise.resolve(undefined)),
102
+ connectSlackInstallation:
103
+ options.connectSlackInstallation ??
104
+ vi.fn((input: ConnectSlackInstallationInput) =>
105
+ Promise.resolve(connection({workspaceId: input.workspaceId})),
106
+ ),
107
+ disconnectSlackInstallation: vi.fn(() => Promise.resolve()),
108
+ },
109
+ });
110
+ const app = await createApp({auth: [fakeUserAuth], routes: provider.routes, swagger: false});
111
+ await app.ready();
112
+ return app;
113
+ }
114
+
115
+ describe('Slack integration routes', () => {
116
+ beforeEach(async () => {
117
+ authenticatedMemberships = [];
118
+ await closeApp();
119
+ });
120
+
121
+ afterEach(async () => {
122
+ await closeApp();
123
+ });
124
+
125
+ it('returns Slack’s bot-only OAuth URL with signed workspace state', async () => {
126
+ const app = await createTestApp();
127
+ const workspaceId = '00000000-0000-4000-8000-000000000002';
128
+ authenticatedMemberships = [{workspaceId, role: 'admin'}];
129
+
130
+ const res = await app.inject({
131
+ method: 'POST',
132
+ url: '/integrations/slack/install',
133
+ headers: {authorization: 'Bearer user'},
134
+ payload: {workspace_id: workspaceId},
135
+ });
136
+
137
+ const installUrl = new URL(res.json().install_url);
138
+ const state = installUrl.searchParams.get('state');
139
+ expect(res.statusCode).toBe(200);
140
+ expect(installUrl.origin + installUrl.pathname).toBe('https://slack.com/oauth/v2/authorize');
141
+ expect(installUrl.searchParams.get('scope')).toContain('app_mentions:read');
142
+ expect(installUrl.searchParams.has('user_scope')).toBe(false);
143
+ expect(verifySlackInstallState(state ?? '')).toEqual({workspaceId, userId: 'user-1'});
144
+ });
145
+
146
+ it('rejects install requests for a workspace the actor cannot access', async () => {
147
+ const app = await createTestApp();
148
+
149
+ const res = await app.inject({
150
+ method: 'POST',
151
+ url: '/integrations/slack/install',
152
+ headers: {authorization: 'Bearer user'},
153
+ payload: {workspace_id: '00000000-0000-4000-8000-000000000002'},
154
+ });
155
+
156
+ expect(res.statusCode).toBe(403);
157
+ });
158
+
159
+ it.each([
160
+ {
161
+ capability: 'without agent tools',
162
+ agentTools: undefined,
163
+ expectedCapabilities: [],
164
+ },
165
+ {
166
+ capability: 'with agent tools',
167
+ agentTools: {tokenStore: {getAccessToken: vi.fn(() => Promise.resolve('xoxb-token'))}},
168
+ expectedCapabilities: ['agent_tools'],
169
+ },
170
+ ])('returns the connected Slack connection $capability', async ({
171
+ agentTools,
172
+ expectedCapabilities,
173
+ }) => {
174
+ const tokenStore = {storeTokens: vi.fn(() => Promise.resolve())};
175
+ const app = await createTestApp({tokenStore, agentTools});
176
+ const workspaceId = '00000000-0000-4000-8000-000000000002';
177
+ authenticatedMemberships = [{workspaceId, role: 'admin'}];
178
+ const install = await app.inject({
179
+ method: 'POST',
180
+ url: '/integrations/slack/install',
181
+ headers: {authorization: 'Bearer user'},
182
+ payload: {workspace_id: workspaceId},
183
+ });
184
+ const state = new URL(install.json().install_url).searchParams.get('state');
185
+
186
+ const res = await app.inject({
187
+ method: 'GET',
188
+ url: `/integrations/slack/callback/api?code=code&state=${state}`,
189
+ headers: {authorization: 'Bearer user'},
190
+ });
191
+
192
+ expect(res.statusCode).toBe(200);
193
+ expect(res.json()).toMatchObject({provider: 'slack', capabilities: expectedCapabilities});
194
+ expect(tokenStore.storeTokens).toHaveBeenCalledWith({
195
+ connectionId: '00000000-0000-4000-8000-000000000001',
196
+ botToken: 'xoxb-token',
197
+ editedBy: 'user-1',
198
+ });
199
+ });
200
+
201
+ it('rejects a callback whose OAuth response enables token rotation', async () => {
202
+ const tokenStore = {storeTokens: vi.fn(() => Promise.resolve())};
203
+ const slack = slackClient({
204
+ exchangeAuthorizationCode: vi.fn(() =>
205
+ Promise.reject(new SlackTokenRotationUnsupportedError()),
206
+ ),
207
+ });
208
+ const app = await createTestApp({slack, tokenStore});
209
+ const workspaceId = '00000000-0000-4000-8000-000000000002';
210
+ authenticatedMemberships = [{workspaceId, role: 'admin'}];
211
+ const install = await app.inject({
212
+ method: 'POST',
213
+ url: '/integrations/slack/install',
214
+ headers: {authorization: 'Bearer user'},
215
+ payload: {workspace_id: workspaceId},
216
+ });
217
+ const state = new URL(install.json().install_url).searchParams.get('state');
218
+
219
+ const res = await app.inject({
220
+ method: 'GET',
221
+ url: `/integrations/slack/callback/api?code=code&state=${state}`,
222
+ headers: {authorization: 'Bearer user'},
223
+ });
224
+
225
+ expect(res.statusCode).toBe(422);
226
+ expect(res.json().code).toBe('slack-token-rotation-unsupported');
227
+ expect(tokenStore.storeTokens).not.toHaveBeenCalled();
228
+ });
229
+ });
@@ -0,0 +1,117 @@
1
+ import {AUTH_USER, requireUserContext, requireWorkspaceAccess} from '@shipfox/api-auth-context';
2
+ import type {IntegrationCapability, IntegrationConnection} from '@shipfox/api-integration-core-dto';
3
+ import {
4
+ createSlackInstallBodySchema,
5
+ createSlackInstallResponseSchema,
6
+ type SlackCallbackQueryDto,
7
+ slackCallbackQuerySchema,
8
+ slackCallbackResponseSchema,
9
+ } from '@shipfox/api-integration-slack-dto';
10
+ import {requireWorkspaceMembership} from '@shipfox/api-workspaces';
11
+ import {defineRoute, type RouteGroup} from '@shipfox/node-fastify';
12
+ import type {SlackApiClient} from '#api/client.js';
13
+ import {config} from '#config.js';
14
+ import {
15
+ type ConnectSlackInstallationInput,
16
+ handleSlackCallback,
17
+ handleSlackOAuthCallbackError,
18
+ } from '#core/install.js';
19
+ import {formatSlackBotScopes} from '#core/scopes.js';
20
+ import {signSlackInstallState} from '#core/state.js';
21
+ import type {SlackTokenStore} from '#core/tokens.js';
22
+ import {toIntegrationConnectionDto} from '#presentation/dto/integrations.js';
23
+ import {slackRouteErrorHandler} from './errors.js';
24
+
25
+ export interface CreateSlackIntegrationRoutesOptions {
26
+ slack: SlackApiClient;
27
+ tokenStore: Pick<SlackTokenStore, 'storeTokens'>;
28
+ getExistingSlackConnection: (input: {
29
+ teamId: string;
30
+ }) => Promise<IntegrationConnection<'slack'> | undefined>;
31
+ connectSlackInstallation: (
32
+ input: ConnectSlackInstallationInput,
33
+ ) => Promise<IntegrationConnection<'slack'>>;
34
+ disconnectSlackInstallation: (input: {connectionId: string}) => Promise<void>;
35
+ connectionCapabilities: IntegrationCapability[];
36
+ }
37
+
38
+ export function createSlackIntegrationRoutes({
39
+ slack,
40
+ tokenStore,
41
+ getExistingSlackConnection,
42
+ connectSlackInstallation,
43
+ disconnectSlackInstallation,
44
+ connectionCapabilities,
45
+ }: CreateSlackIntegrationRoutesOptions): RouteGroup {
46
+ const installRoute = defineRoute({
47
+ method: 'POST',
48
+ path: '/install',
49
+ auth: AUTH_USER,
50
+ description: 'Create a Slack OAuth authorization URL for a workspace.',
51
+ schema: {
52
+ body: createSlackInstallBodySchema,
53
+ response: {200: createSlackInstallResponseSchema},
54
+ },
55
+ handler: (request) => {
56
+ const {workspace_id: workspaceId} = request.body;
57
+ const actor = requireUserContext(request);
58
+ requireWorkspaceAccess({request, workspaceId});
59
+ const state = signSlackInstallState({workspaceId, userId: actor.userId});
60
+ const installUrl = new URL('https://slack.com/oauth/v2/authorize');
61
+ installUrl.searchParams.set('client_id', config.SLACK_OAUTH_CLIENT_ID);
62
+ installUrl.searchParams.set('scope', formatSlackBotScopes());
63
+ installUrl.searchParams.set('redirect_uri', config.SLACK_OAUTH_REDIRECT_URL);
64
+ installUrl.searchParams.set('state', state);
65
+ return {install_url: installUrl.toString()};
66
+ },
67
+ });
68
+
69
+ const callbackApiRoute = defineRoute({
70
+ method: 'GET',
71
+ path: '/callback/api',
72
+ auth: AUTH_USER,
73
+ description: 'Handle the Slack OAuth callback.',
74
+ schema: {
75
+ querystring: slackCallbackQuerySchema,
76
+ response: {200: slackCallbackResponseSchema},
77
+ },
78
+ errorHandler: slackRouteErrorHandler,
79
+ handler: async (request) => {
80
+ const actor = requireUserContext(request);
81
+ const query = request.query;
82
+ if (isSlackOAuthErrorCallback(query)) {
83
+ return await handleSlackOAuthCallbackError({
84
+ state: query.state,
85
+ error: query.error,
86
+ errorDescription: query.error_description,
87
+ sessionUserId: actor.userId,
88
+ sessionMemberships: actor.memberships,
89
+ requireWorkspaceMembership,
90
+ });
91
+ }
92
+ const connection = await handleSlackCallback({
93
+ slack,
94
+ tokenStore,
95
+ code: query.code,
96
+ state: query.state,
97
+ sessionUserId: actor.userId,
98
+ sessionMemberships: actor.memberships,
99
+ requireWorkspaceMembership,
100
+ getExistingSlackConnection,
101
+ connectSlackInstallation,
102
+ disconnectSlackInstallation,
103
+ });
104
+ return toIntegrationConnectionDto(connection, {capabilities: connectionCapabilities});
105
+ },
106
+ });
107
+
108
+ return {prefix: '/integrations/slack', routes: [installRoute, callbackApiRoute]};
109
+ }
110
+
111
+ function isSlackOAuthErrorCallback(query: SlackCallbackQueryDto): query is SlackCallbackQueryDto & {
112
+ error: string;
113
+ error_description?: string | undefined;
114
+ state: string;
115
+ } {
116
+ return 'error' in query && typeof query.error === 'string';
117
+ }
@@ -0,0 +1,266 @@
1
+ import {createHmac, randomUUID} from 'node:crypto';
2
+ import type {IntegrationConnection} from '@shipfox/api-integration-core-dto';
3
+ import {closeApp, createApp} from '@shipfox/node-fastify';
4
+ import {db} from '#db/db.js';
5
+ import {upsertSlackInstallation} from '#db/installations.js';
6
+ import {slackInstallations} from '#db/schema/installations.js';
7
+ import {createSlackWebhookRoutes, SLASH_COMMAND_ACK} from './webhooks.js';
8
+
9
+ const signingSecret = 'test-signing-secret';
10
+
11
+ function fakeConnection(): IntegrationConnection {
12
+ return {
13
+ id: randomUUID(),
14
+ workspaceId: randomUUID(),
15
+ provider: 'slack',
16
+ externalAccountId: 'T1',
17
+ slug: 'slack_acme',
18
+ displayName: 'Slack Acme',
19
+ lifecycleStatus: 'active',
20
+ createdAt: new Date(),
21
+ updatedAt: new Date(),
22
+ };
23
+ }
24
+
25
+ function slackHeaders(
26
+ rawBody: string,
27
+ contentType: string,
28
+ timestamp = `${Math.floor(Date.now() / 1000)}`,
29
+ ) {
30
+ const hex = createHmac('sha256', signingSecret)
31
+ .update(`v0:${timestamp}:${rawBody}`)
32
+ .digest('hex');
33
+ return {
34
+ 'content-type': contentType,
35
+ 'x-slack-request-timestamp': timestamp,
36
+ 'x-slack-signature': `v0=${hex}`,
37
+ };
38
+ }
39
+
40
+ async function createTestApp(connection = fakeConnection()): Promise<{
41
+ app: Awaited<ReturnType<typeof createApp>>;
42
+ connection: IntegrationConnection;
43
+ publishIntegrationEventReceived: ReturnType<typeof vi.fn>;
44
+ recordDeliveryOnly: ReturnType<typeof vi.fn>;
45
+ getIntegrationConnectionById: ReturnType<typeof vi.fn>;
46
+ }> {
47
+ const publishIntegrationEventReceived = vi.fn(() => Promise.resolve({published: true}));
48
+ const recordDeliveryOnly = vi.fn(() => Promise.resolve());
49
+ const getIntegrationConnectionById = vi.fn(() => Promise.resolve(connection));
50
+ const app = await createApp({
51
+ routes: createSlackWebhookRoutes({
52
+ coreDb: db,
53
+ publishIntegrationEventReceived,
54
+ recordDeliveryOnly,
55
+ getIntegrationConnectionById,
56
+ }),
57
+ swagger: false,
58
+ });
59
+ await app.ready();
60
+ return {
61
+ app,
62
+ connection,
63
+ publishIntegrationEventReceived,
64
+ recordDeliveryOnly,
65
+ getIntegrationConnectionById,
66
+ };
67
+ }
68
+
69
+ async function seedInstallation(connection: IntegrationConnection): Promise<void> {
70
+ await upsertSlackInstallation({
71
+ connectionId: connection.id,
72
+ teamId: 'T1',
73
+ teamName: 'Acme',
74
+ appId: 'A1',
75
+ botUserId: 'UBOT',
76
+ scopes: [],
77
+ status: 'installed',
78
+ });
79
+ }
80
+
81
+ describe('Slack webhook routes', () => {
82
+ beforeEach(async () => {
83
+ await closeApp();
84
+ await db().delete(slackInstallations);
85
+ });
86
+
87
+ afterEach(async () => {
88
+ await closeApp();
89
+ });
90
+
91
+ it('answers a signed URL verification before resolving an installation', async () => {
92
+ const {app, getIntegrationConnectionById} = await createTestApp();
93
+ const rawBody = JSON.stringify({
94
+ type: 'url_verification',
95
+ token: 'token',
96
+ challenge: 'challenge',
97
+ });
98
+
99
+ const response = await app.inject({
100
+ method: 'POST',
101
+ url: '/webhooks/integrations/slack/events',
102
+ headers: slackHeaders(rawBody, 'application/json'),
103
+ payload: rawBody,
104
+ });
105
+
106
+ expect(response.statusCode).toBe(200);
107
+ expect(response.json()).toEqual({challenge: 'challenge'});
108
+ expect(getIntegrationConnectionById).not.toHaveBeenCalled();
109
+ });
110
+
111
+ it('rejects an unsigned request before parsing its body', async () => {
112
+ const {app, publishIntegrationEventReceived, recordDeliveryOnly} = await createTestApp();
113
+
114
+ const response = await app.inject({
115
+ method: 'POST',
116
+ url: '/webhooks/integrations/slack/events',
117
+ headers: {'content-type': 'application/json'},
118
+ payload: '{"type":',
119
+ });
120
+
121
+ expect(response.statusCode).toBe(401);
122
+ expect(publishIntegrationEventReceived).not.toHaveBeenCalled();
123
+ expect(recordDeliveryOnly).not.toHaveBeenCalled();
124
+ });
125
+
126
+ it('rejects a stale signed event before parsing it', async () => {
127
+ const {app, publishIntegrationEventReceived, recordDeliveryOnly} = await createTestApp();
128
+ const rawBody = JSON.stringify({
129
+ type: 'url_verification',
130
+ token: 'token',
131
+ challenge: 'challenge',
132
+ });
133
+
134
+ const response = await app.inject({
135
+ method: 'POST',
136
+ url: '/webhooks/integrations/slack/events',
137
+ headers: slackHeaders(rawBody, 'application/json', `${Math.floor(Date.now() / 1000) - 301}`),
138
+ payload: rawBody,
139
+ });
140
+
141
+ expect(response.statusCode).toBe(401);
142
+ expect(publishIntegrationEventReceived).not.toHaveBeenCalled();
143
+ expect(recordDeliveryOnly).not.toHaveBeenCalled();
144
+ });
145
+
146
+ it('rejects signed malformed event JSON', async () => {
147
+ const {app, publishIntegrationEventReceived, recordDeliveryOnly} = await createTestApp();
148
+ const rawBody = '{"type":';
149
+
150
+ const response = await app.inject({
151
+ method: 'POST',
152
+ url: '/webhooks/integrations/slack/events',
153
+ headers: slackHeaders(rawBody, 'application/json'),
154
+ payload: rawBody,
155
+ });
156
+
157
+ expect(response.statusCode).toBe(400);
158
+ expect(publishIntegrationEventReceived).not.toHaveBeenCalled();
159
+ expect(recordDeliveryOnly).not.toHaveBeenCalled();
160
+ });
161
+
162
+ it('publishes a signed event delivery through the route transaction', async () => {
163
+ const {app, connection, publishIntegrationEventReceived} = await createTestApp();
164
+ await seedInstallation(connection);
165
+ const rawBody = JSON.stringify({
166
+ type: 'event_callback',
167
+ team_id: 'T1',
168
+ api_app_id: 'A1',
169
+ event: {type: 'app_mention', channel: 'C1', user: 'U1', text: 'hello', ts: '1.0'},
170
+ event_id: 'Ev-route',
171
+ event_time: 1_721_300_000,
172
+ });
173
+
174
+ const response = await app.inject({
175
+ method: 'POST',
176
+ url: '/webhooks/integrations/slack/events',
177
+ headers: slackHeaders(rawBody, 'application/json'),
178
+ payload: rawBody,
179
+ });
180
+
181
+ expect(response.statusCode).toBe(200);
182
+ expect(publishIntegrationEventReceived).toHaveBeenCalledWith(
183
+ expect.objectContaining({
184
+ event: expect.objectContaining({event: 'app_mention', deliveryId: 'Ev-route'}),
185
+ }),
186
+ );
187
+ });
188
+
189
+ it('returns the fixed acknowledgement for a signed command that cannot resolve a team', async () => {
190
+ const {app, publishIntegrationEventReceived, recordDeliveryOnly} = await createTestApp();
191
+ const rawBody = new URLSearchParams({
192
+ token: 'verification-token',
193
+ command: '/deploy',
194
+ team_id: 'T-missing',
195
+ channel_id: 'C1',
196
+ user_id: 'U1',
197
+ response_url: 'https://hooks.slack.com/commands/1',
198
+ trigger_id: '1337.42',
199
+ text: '',
200
+ }).toString();
201
+
202
+ const response = await app.inject({
203
+ method: 'POST',
204
+ url: '/webhooks/integrations/slack/commands',
205
+ headers: slackHeaders(rawBody, 'application/x-www-form-urlencoded'),
206
+ payload: rawBody,
207
+ });
208
+
209
+ expect(response.statusCode).toBe(200);
210
+ expect(response.json()).toEqual(SLASH_COMMAND_ACK);
211
+ expect(publishIntegrationEventReceived).not.toHaveBeenCalled();
212
+ expect(recordDeliveryOnly).toHaveBeenCalledWith(
213
+ expect.objectContaining({provider: 'slack', deliveryId: '1337.42'}),
214
+ );
215
+ });
216
+
217
+ it('returns the acknowledgement for a signed command that fails validation', async () => {
218
+ const {app, publishIntegrationEventReceived, recordDeliveryOnly} = await createTestApp();
219
+ const rawBody = new URLSearchParams({team_id: 'T1'}).toString();
220
+
221
+ const response = await app.inject({
222
+ method: 'POST',
223
+ url: '/webhooks/integrations/slack/commands',
224
+ headers: slackHeaders(rawBody, 'application/x-www-form-urlencoded'),
225
+ payload: rawBody,
226
+ });
227
+
228
+ expect(response.statusCode).toBe(200);
229
+ expect(response.json()).toEqual(SLASH_COMMAND_ACK);
230
+ expect(publishIntegrationEventReceived).not.toHaveBeenCalled();
231
+ expect(recordDeliveryOnly).not.toHaveBeenCalled();
232
+ });
233
+
234
+ it('publishes a signed command without exposing its verification token', async () => {
235
+ const {app, connection, publishIntegrationEventReceived} = await createTestApp();
236
+ await seedInstallation(connection);
237
+ const rawBody = new URLSearchParams({
238
+ token: 'verification-token',
239
+ command: '/deploy',
240
+ team_id: 'T1',
241
+ channel_id: 'C1',
242
+ user_id: 'U1',
243
+ response_url: 'https://hooks.slack.com/commands/1',
244
+ trigger_id: '1337.43',
245
+ text: 'production',
246
+ }).toString();
247
+
248
+ const response = await app.inject({
249
+ method: 'POST',
250
+ url: '/webhooks/integrations/slack/commands',
251
+ headers: slackHeaders(rawBody, 'application/x-www-form-urlencoded'),
252
+ payload: rawBody,
253
+ });
254
+
255
+ expect(response.statusCode).toBe(200);
256
+ expect(publishIntegrationEventReceived).toHaveBeenCalledWith(
257
+ expect.objectContaining({
258
+ event: expect.objectContaining({
259
+ event: 'slash_command',
260
+ deliveryId: '1337.43',
261
+ payload: expect.not.objectContaining({token: expect.anything()}),
262
+ }),
263
+ }),
264
+ );
265
+ });
266
+ });