@shipfox/api-integration-slack 4.0.0 → 6.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 (72) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +59 -0
  3. package/dist/api/client.d.ts.map +1 -1
  4. package/dist/api/client.js +3 -1
  5. package/dist/api/client.js.map +1 -1
  6. package/dist/core/signature.d.ts +2 -1
  7. package/dist/core/signature.d.ts.map +1 -1
  8. package/dist/core/signature.js +10 -6
  9. package/dist/core/signature.js.map +1 -1
  10. package/dist/core/webhook-processor.d.ts +15 -0
  11. package/dist/core/webhook-processor.d.ts.map +1 -0
  12. package/dist/core/webhook-processor.js +127 -0
  13. package/dist/core/webhook-processor.js.map +1 -0
  14. package/dist/core/webhook.d.ts +5 -3
  15. package/dist/core/webhook.d.ts.map +1 -1
  16. package/dist/core/webhook.js +101 -2
  17. package/dist/core/webhook.js.map +1 -1
  18. package/dist/db/db.d.ts +34 -0
  19. package/dist/db/db.d.ts.map +1 -1
  20. package/dist/db/installations.d.ts +2 -0
  21. package/dist/db/installations.d.ts.map +1 -1
  22. package/dist/db/installations.js +4 -2
  23. package/dist/db/installations.js.map +1 -1
  24. package/dist/db/schema/installations.d.ts +17 -0
  25. package/dist/db/schema/installations.d.ts.map +1 -1
  26. package/dist/db/schema/installations.js +3 -1
  27. package/dist/db/schema/installations.js.map +1 -1
  28. package/dist/index.d.ts +28 -2
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +26 -7
  31. package/dist/index.js.map +1 -1
  32. package/dist/presentation/routes/errors.d.ts.map +1 -1
  33. package/dist/presentation/routes/errors.js +22 -0
  34. package/dist/presentation/routes/errors.js.map +1 -1
  35. package/dist/presentation/routes/install.d.ts +6 -1
  36. package/dist/presentation/routes/install.d.ts.map +1 -1
  37. package/dist/presentation/routes/install.js +6 -4
  38. package/dist/presentation/routes/install.js.map +1 -1
  39. package/dist/presentation/routes/webhooks.d.ts +4 -1
  40. package/dist/presentation/routes/webhooks.d.ts.map +1 -1
  41. package/dist/presentation/routes/webhooks.js +94 -92
  42. package/dist/presentation/routes/webhooks.js.map +1 -1
  43. package/dist/tsconfig.test.tsbuildinfo +1 -1
  44. package/drizzle/0001_dark_tyrannus.sql +1 -0
  45. package/drizzle/meta/0001_snapshot.json +139 -0
  46. package/drizzle/meta/_journal.json +7 -0
  47. package/package.json +20 -30
  48. package/src/api/client.test.ts +24 -0
  49. package/src/api/client.ts +3 -1
  50. package/src/core/disconnect.test.ts +96 -0
  51. package/src/core/signature.test.ts +3 -3
  52. package/src/core/signature.ts +19 -9
  53. package/src/core/tokens.test.ts +16 -2
  54. package/src/core/webhook-processor.test.ts +106 -0
  55. package/src/core/webhook-processor.ts +152 -0
  56. package/src/core/webhook.test.ts +131 -2
  57. package/src/core/webhook.ts +143 -6
  58. package/src/db/installations.test.ts +15 -0
  59. package/src/db/installations.ts +11 -3
  60. package/src/db/schema/installations.ts +3 -1
  61. package/src/index.test.ts +11 -0
  62. package/src/index.ts +30 -3
  63. package/src/presentation/routes/errors.test.ts +25 -0
  64. package/src/presentation/routes/errors.ts +20 -0
  65. package/src/presentation/routes/install.test.ts +2 -4
  66. package/src/presentation/routes/install.ts +18 -3
  67. package/src/presentation/routes/webhooks.test.ts +46 -1
  68. package/src/presentation/routes/webhooks.ts +127 -97
  69. package/test/globalSetup.ts +0 -9
  70. package/tsconfig.build.tsbuildinfo +1 -1
  71. package/turbo.json +9 -0
  72. package/test/api-secrets.d.ts +0 -26
@@ -0,0 +1 @@
1
+ ALTER TABLE "integrations_slack_installations" ADD COLUMN "generation" integer DEFAULT 1 NOT NULL;
@@ -0,0 +1,139 @@
1
+ {
2
+ "id": "5acf9796-7da0-4134-b7f8-000a1a5a7de0",
3
+ "prevId": "62801d4c-2399-4306-8d37-9773d10cbb02",
4
+ "version": "7",
5
+ "dialect": "postgresql",
6
+ "tables": {
7
+ "public.integrations_slack_installations": {
8
+ "name": "integrations_slack_installations",
9
+ "schema": "",
10
+ "columns": {
11
+ "id": {
12
+ "name": "id",
13
+ "type": "uuid",
14
+ "primaryKey": true,
15
+ "notNull": true,
16
+ "default": "uuidv7()"
17
+ },
18
+ "connection_id": {
19
+ "name": "connection_id",
20
+ "type": "uuid",
21
+ "primaryKey": false,
22
+ "notNull": true
23
+ },
24
+ "team_id": {
25
+ "name": "team_id",
26
+ "type": "text",
27
+ "primaryKey": false,
28
+ "notNull": true
29
+ },
30
+ "team_name": {
31
+ "name": "team_name",
32
+ "type": "text",
33
+ "primaryKey": false,
34
+ "notNull": true
35
+ },
36
+ "app_id": {
37
+ "name": "app_id",
38
+ "type": "text",
39
+ "primaryKey": false,
40
+ "notNull": true
41
+ },
42
+ "bot_user_id": {
43
+ "name": "bot_user_id",
44
+ "type": "text",
45
+ "primaryKey": false,
46
+ "notNull": true
47
+ },
48
+ "scopes": {
49
+ "name": "scopes",
50
+ "type": "jsonb",
51
+ "primaryKey": false,
52
+ "notNull": true
53
+ },
54
+ "status": {
55
+ "name": "status",
56
+ "type": "text",
57
+ "primaryKey": false,
58
+ "notNull": true
59
+ },
60
+ "generation": {
61
+ "name": "generation",
62
+ "type": "integer",
63
+ "primaryKey": false,
64
+ "notNull": true,
65
+ "default": 1
66
+ },
67
+ "token_expires_at": {
68
+ "name": "token_expires_at",
69
+ "type": "timestamp with time zone",
70
+ "primaryKey": false,
71
+ "notNull": false
72
+ },
73
+ "created_at": {
74
+ "name": "created_at",
75
+ "type": "timestamp with time zone",
76
+ "primaryKey": false,
77
+ "notNull": true,
78
+ "default": "now()"
79
+ },
80
+ "updated_at": {
81
+ "name": "updated_at",
82
+ "type": "timestamp with time zone",
83
+ "primaryKey": false,
84
+ "notNull": true,
85
+ "default": "now()"
86
+ }
87
+ },
88
+ "indexes": {
89
+ "integrations_slack_installations_connection_unique": {
90
+ "name": "integrations_slack_installations_connection_unique",
91
+ "columns": [
92
+ {
93
+ "expression": "connection_id",
94
+ "isExpression": false,
95
+ "asc": true,
96
+ "nulls": "last"
97
+ }
98
+ ],
99
+ "isUnique": true,
100
+ "concurrently": false,
101
+ "method": "btree",
102
+ "with": {}
103
+ },
104
+ "integrations_slack_installations_team_unique": {
105
+ "name": "integrations_slack_installations_team_unique",
106
+ "columns": [
107
+ {
108
+ "expression": "team_id",
109
+ "isExpression": false,
110
+ "asc": true,
111
+ "nulls": "last"
112
+ }
113
+ ],
114
+ "isUnique": true,
115
+ "concurrently": false,
116
+ "method": "btree",
117
+ "with": {}
118
+ }
119
+ },
120
+ "foreignKeys": {},
121
+ "compositePrimaryKeys": {},
122
+ "uniqueConstraints": {},
123
+ "policies": {},
124
+ "checkConstraints": {},
125
+ "isRLSEnabled": false
126
+ }
127
+ },
128
+ "enums": {},
129
+ "schemas": {},
130
+ "sequences": {},
131
+ "roles": {},
132
+ "policies": {},
133
+ "views": {},
134
+ "_meta": {
135
+ "columns": {},
136
+ "schemas": {},
137
+ "tables": {}
138
+ }
139
+ }
@@ -8,6 +8,13 @@
8
8
  "when": 1784372558063,
9
9
  "tag": "0000_calm_fantastic_four",
10
10
  "breakpoints": true
11
+ },
12
+ {
13
+ "idx": 1,
14
+ "version": "7",
15
+ "when": 1784407778470,
16
+ "tag": "0001_dark_tyrannus",
17
+ "breakpoints": true
11
18
  }
12
19
  ]
13
20
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/api-integration-slack",
3
3
  "license": "MIT",
4
- "version": "4.0.0",
4
+ "version": "6.0.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -13,56 +13,46 @@
13
13
  "types": "dist/index.d.ts",
14
14
  "imports": {
15
15
  "#test/*": "./test/*",
16
- "#*": {
17
- "workspace-source": "./src/*",
18
- "development": "./src/*",
19
- "default": "./dist/*"
20
- }
16
+ "#*": "./dist/*"
21
17
  },
22
18
  "exports": {
23
19
  ".": {
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
- }
20
+ "types": "./dist/index.d.ts",
21
+ "default": "./dist/index.js"
32
22
  }
33
23
  },
34
24
  "dependencies": {
35
25
  "drizzle-orm": "^0.45.2",
36
26
  "ky": "^2.0.0",
37
27
  "zod": "^4.4.3",
38
- "@shipfox/api-auth-context": "3.0.0",
39
- "@shipfox/api-integration-core-dto": "3.0.0",
40
- "@shipfox/config": "1.2.1",
41
- "@shipfox/api-integration-slack-dto": "4.0.0",
42
- "@shipfox/api-workspaces": "4.0.0",
43
- "@shipfox/node-drizzle": "0.3.0",
44
- "@shipfox/node-fastify": "0.2.2",
45
- "@shipfox/node-opentelemetry": "0.5.1",
46
- "@shipfox/node-postgres": "0.4.1"
47
- },
48
- "peerDependencies": {
49
- "@shipfox/api-secrets": "4.0.0"
28
+ "@shipfox/api-auth-context": "6.0.0",
29
+ "@shipfox/api-integration-core-dto": "6.0.0",
30
+ "@shipfox/api-integration-slack-dto": "6.0.0",
31
+ "@shipfox/api-workspaces-dto": "6.0.0",
32
+ "@shipfox/config": "1.2.2",
33
+ "@shipfox/inter-module": "0.2.0",
34
+ "@shipfox/node-drizzle": "0.3.2",
35
+ "@shipfox/node-fastify": "0.2.4",
36
+ "@shipfox/node-opentelemetry": "0.5.2",
37
+ "@shipfox/node-postgres": "0.4.2"
50
38
  },
51
39
  "devDependencies": {
52
40
  "@types/pg": "^8.15.5",
53
41
  "drizzle-kit": "^0.31.10",
54
42
  "fastify": "^5.3.3",
55
43
  "fishery": "^2.4.0",
56
- "@shipfox/swc": "1.2.5",
57
- "@shipfox/biome": "1.8.1",
44
+ "@shipfox/biome": "1.8.2",
45
+ "@shipfox/depcruise": "1.0.2",
46
+ "@shipfox/swc": "1.2.6",
58
47
  "@shipfox/ts-config": "1.3.8",
59
- "@shipfox/typescript": "1.1.6",
60
- "@shipfox/vitest": "1.2.2"
48
+ "@shipfox/typescript": "1.1.7",
49
+ "@shipfox/vitest": "1.2.3"
61
50
  },
62
51
  "scripts": {
63
52
  "build": "shipfox-swc",
64
53
  "check": "shipfox-biome-check",
65
54
  "check:fix": "shipfox-biome-check --write",
55
+ "depcruise": "shipfox-depcruise",
66
56
  "test": "shipfox-vitest-run",
67
57
  "test:watch": "shipfox-vitest-watch",
68
58
  "type": "shipfox-tsc-check",
@@ -217,6 +217,30 @@ describe('createSlackApiClient', () => {
217
217
  expect(options.body.has('latest')).toBe(false);
218
218
  });
219
219
 
220
+ it('normalizes a trailing slash in the Slack API base URL', async () => {
221
+ vi.stubEnv('SLACK_API_BASE_URL', 'http://127.0.0.1:0/');
222
+ vi.resetModules();
223
+ mocks.post.mockReturnValue(resolves({ok: true}));
224
+ try {
225
+ const {createSlackApiClient: createClient} = await import('./client.js');
226
+
227
+ await createClient().callMethod({
228
+ method: 'conversations.replies',
229
+ token: 'xoxb-secret',
230
+ arguments: {channel: 'C123', ts: '1721300000.000001'},
231
+ });
232
+
233
+ expect(mocks.post).toHaveBeenCalledWith('http://127.0.0.1:0/conversations.replies', {
234
+ headers: {authorization: 'Bearer xoxb-secret'},
235
+ body: new URLSearchParams({channel: 'C123', ts: '1721300000.000001'}),
236
+ timeout: 10_000,
237
+ });
238
+ } finally {
239
+ vi.unstubAllEnvs();
240
+ vi.resetModules();
241
+ }
242
+ });
243
+
220
244
  it.each([
221
245
  [httpError(429, {'retry-after': '17'}), 'rate-limited', 17],
222
246
  [httpError(413), 'content-too-large', undefined],
package/src/api/client.ts CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  } from '#core/errors.js';
9
9
 
10
10
  const SLACK_API_TIMEOUT_MS = 10_000;
11
+ const trailingSlashesPattern = /\/+$/;
11
12
 
12
13
  export interface SlackAuthorization {
13
14
  accessToken: string;
@@ -232,7 +233,8 @@ function slackMethodArguments(input: Record<string, unknown>): URLSearchParams {
232
233
  }
233
234
 
234
235
  function slackApiUrl(path: string): string {
235
- return new URL(path, `${config.SLACK_API_BASE_URL}/`).toString();
236
+ const baseUrl = config.SLACK_API_BASE_URL.replace(trailingSlashesPattern, '');
237
+ return new URL(path, `${baseUrl}/`).toString();
236
238
  }
237
239
 
238
240
  function retryAfterSeconds(headers: Headers): number | undefined {
@@ -0,0 +1,96 @@
1
+ import {disconnectSlackInstallation} from './disconnect.js';
2
+
3
+ vi.mock('#db/installations.js', () => ({
4
+ deleteSlackInstallationByConnectionId: vi.fn(() => Promise.resolve(true)),
5
+ }));
6
+
7
+ const {deleteSlackInstallationByConnectionId} = await import('#db/installations.js');
8
+ const deleteSlackInstallationByConnectionIdMock = vi.mocked(deleteSlackInstallationByConnectionId);
9
+
10
+ describe('disconnectSlackInstallation', () => {
11
+ beforeEach(() => {
12
+ deleteSlackInstallationByConnectionIdMock.mockClear();
13
+ deleteSlackInstallationByConnectionIdMock.mockResolvedValue(true);
14
+ });
15
+
16
+ it('deletes stored tokens before deleting connection records', async () => {
17
+ const tx = Symbol('tx');
18
+ const calls: string[] = [];
19
+ const deleteSecrets = vi.fn(() => {
20
+ calls.push('secrets');
21
+ return Promise.resolve(1);
22
+ });
23
+ const deleteConnection = vi.fn(() => {
24
+ calls.push('connection');
25
+ return Promise.resolve(true);
26
+ });
27
+
28
+ await disconnectSlackInstallation({
29
+ connectionId: 'connection-1',
30
+ getConnection: vi.fn(() => Promise.resolve({workspaceId: 'workspace-1'})),
31
+ deleteSecrets,
32
+ transaction: async (fn) => await fn(tx),
33
+ deleteConnection,
34
+ });
35
+
36
+ expect(deleteSecrets).toHaveBeenCalledWith({
37
+ workspaceId: 'workspace-1',
38
+ namespace: 'system/integrations/slack/connection-1',
39
+ });
40
+ expect(deleteSlackInstallationByConnectionIdMock).toHaveBeenCalledWith('connection-1', {tx});
41
+ expect(deleteConnection).toHaveBeenCalledWith({connectionId: 'connection-1'}, {tx});
42
+ expect(calls).toEqual(['secrets', 'connection']);
43
+ expect(deleteSecrets.mock.invocationCallOrder[0]).toBeLessThan(
44
+ deleteSlackInstallationByConnectionIdMock.mock.invocationCallOrder[0] ?? 0,
45
+ );
46
+ expect(deleteSlackInstallationByConnectionIdMock.mock.invocationCallOrder[0]).toBeLessThan(
47
+ deleteConnection.mock.invocationCallOrder[0] ?? 0,
48
+ );
49
+ });
50
+
51
+ it('keeps connection records when secret deletion fails', async () => {
52
+ const transaction = vi.fn();
53
+ const deleteConnection = vi.fn();
54
+
55
+ const run = disconnectSlackInstallation({
56
+ connectionId: 'connection-1',
57
+ getConnection: vi.fn(() => Promise.resolve({workspaceId: 'workspace-1'})),
58
+ deleteSecrets: vi.fn(() => Promise.reject(new Error('secret store unavailable'))),
59
+ transaction,
60
+ deleteConnection,
61
+ });
62
+
63
+ await expect(run).rejects.toThrow('secret store unavailable');
64
+ expect(transaction).not.toHaveBeenCalled();
65
+ expect(deleteSlackInstallationByConnectionIdMock).not.toHaveBeenCalled();
66
+ expect(deleteConnection).not.toHaveBeenCalled();
67
+ });
68
+
69
+ it('retries idempotent secret deletion after the records transaction fails', async () => {
70
+ const tx = Symbol('tx');
71
+ const deleteSecrets = vi.fn(() => Promise.resolve(0));
72
+ const deleteConnection = vi.fn(() => Promise.resolve(true));
73
+ const transaction = vi
74
+ .fn()
75
+ .mockImplementationOnce(async (fn: (value: symbol) => Promise<unknown>) => {
76
+ await fn(tx);
77
+ throw new Error('database commit failed');
78
+ })
79
+ .mockImplementationOnce(async (fn: (value: symbol) => Promise<unknown>) => await fn(tx));
80
+ const params = {
81
+ connectionId: 'connection-1',
82
+ getConnection: vi.fn(() => Promise.resolve({workspaceId: 'workspace-1'})),
83
+ deleteSecrets,
84
+ transaction,
85
+ deleteConnection,
86
+ };
87
+
88
+ const firstAttempt = disconnectSlackInstallation(params);
89
+ await expect(firstAttempt).rejects.toThrow('database commit failed');
90
+ await disconnectSlackInstallation(params);
91
+
92
+ expect(deleteSecrets).toHaveBeenCalledTimes(2);
93
+ expect(deleteSlackInstallationByConnectionIdMock).toHaveBeenCalledTimes(2);
94
+ expect(deleteConnection).toHaveBeenCalledTimes(2);
95
+ });
96
+ });
@@ -3,11 +3,11 @@ import {verifySlackSignature} from './signature.js';
3
3
 
4
4
  const secret = 'test-signing-secret';
5
5
  const timestamp = '1721300000';
6
- const rawBody = '{"type":"event_callback"}';
6
+ const rawBody = Buffer.from('{"type":"event_callback"}');
7
7
  const now = Number(timestamp) * 1000;
8
8
 
9
9
  function signature(body = rawBody): string {
10
- return `v0=${createHmac('sha256', secret).update(`v0:${timestamp}:${body}`).digest('hex')}`;
10
+ return `v0=${createHmac('sha256', secret).update(`v0:${timestamp}:`).update(body).digest('hex')}`;
11
11
  }
12
12
 
13
13
  describe('verifySlackSignature', () => {
@@ -24,7 +24,7 @@ describe('verifySlackSignature', () => {
24
24
  });
25
25
 
26
26
  it.each([
27
- ['a tampered body', {rawBody: '{"type":"tampered"}'}],
27
+ ['a tampered body', {rawBody: Buffer.from('{"type":"tampered"}')}],
28
28
  ['a missing signature', {signature: undefined}],
29
29
  ['an empty signing secret', {signingSecret: ''}],
30
30
  ['a missing timestamp', {timestamp: undefined}],
@@ -6,11 +6,25 @@ export interface VerifySlackSignatureParams {
6
6
  signingSecret: string;
7
7
  signature: string | undefined;
8
8
  timestamp: string | undefined;
9
- rawBody: string;
9
+ rawBody: Uint8Array;
10
10
  now?: number | undefined;
11
11
  replayWindowMs?: number | undefined;
12
12
  }
13
13
 
14
+ export function isSlackTimestampWithinReplayWindow(
15
+ timestamp: string,
16
+ now = Date.now(),
17
+ replayWindowMs = 300_000,
18
+ ): boolean {
19
+ if (!unixTimestampPattern.test(timestamp)) return false;
20
+
21
+ const timestampSeconds = Number(timestamp);
22
+ if (!Number.isSafeInteger(timestampSeconds)) return false;
23
+ const timestampMilliseconds = timestampSeconds * 1000;
24
+ if (!Number.isSafeInteger(timestampMilliseconds)) return false;
25
+ return Math.abs(now - timestampMilliseconds) <= replayWindowMs;
26
+ }
27
+
14
28
  export function verifySlackSignature({
15
29
  signingSecret,
16
30
  signature,
@@ -19,18 +33,14 @@ export function verifySlackSignature({
19
33
  now = Date.now(),
20
34
  replayWindowMs = 300_000,
21
35
  }: VerifySlackSignatureParams): boolean {
22
- if (!signingSecret || !signature || !timestamp || !unixTimestampPattern.test(timestamp)) {
36
+ if (!signingSecret || !signature || !timestamp) {
23
37
  return false;
24
38
  }
25
-
26
- const timestampSeconds = Number(timestamp);
27
- if (!Number.isSafeInteger(timestampSeconds)) return false;
28
- const timestampMilliseconds = timestampSeconds * 1000;
29
- if (!Number.isSafeInteger(timestampMilliseconds)) return false;
30
- if (Math.abs(now - timestampMilliseconds) > replayWindowMs) return false;
39
+ if (!isSlackTimestampWithinReplayWindow(timestamp, now, replayWindowMs)) return false;
31
40
 
32
41
  const expected = `v0=${createHmac('sha256', signingSecret)
33
- .update(`v0:${timestamp}:${rawBody}`)
42
+ .update(`v0:${timestamp}:`)
43
+ .update(rawBody)
34
44
  .digest('hex')}`;
35
45
  const expectedBuffer = Buffer.from(expected, 'utf8');
36
46
  const receivedBuffer = Buffer.from(signature, 'utf8');
@@ -9,8 +9,8 @@ import {
9
9
 
10
10
  let slackSecrets: SlackSecretsStore;
11
11
 
12
- beforeAll(async () => {
13
- slackSecrets = await import('@shipfox/api-secrets');
12
+ beforeEach(() => {
13
+ slackSecrets = createInMemorySecretsStore();
14
14
  });
15
15
 
16
16
  function createConnectionContext() {
@@ -161,3 +161,17 @@ describe('createSlackTokenStore', () => {
161
161
  await expect(result).rejects.toBeInstanceOf(SlackConnectionNotFoundError);
162
162
  });
163
163
  });
164
+
165
+ function createInMemorySecretsStore(): SlackSecretsStore {
166
+ const values = new Map<string, string>();
167
+ const id = (params: {workspaceId: string; namespace: string; key: string}) =>
168
+ `${params.workspaceId}\0${params.namespace}\0${params.key}`;
169
+ return {
170
+ getSecret: async (params) => values.get(id(params)) ?? null,
171
+ setSecrets: async (params) => {
172
+ await Promise.resolve();
173
+ for (const [key, value] of Object.entries(params.values))
174
+ values.set(id({...params, key}), value);
175
+ },
176
+ };
177
+ }
@@ -0,0 +1,106 @@
1
+ import {createHmac, randomUUID} from 'node:crypto';
2
+ import type {IntegrationConnection} from '@shipfox/api-integration-core-dto';
3
+ import {createStoredWebhookRequest} from '@shipfox/api-integration-core-dto';
4
+ import {db} from '#db/db.js';
5
+ import {upsertSlackInstallation} from '#db/installations.js';
6
+ import {slackInstallations} from '#db/schema/installations.js';
7
+ import {createSlackWebhookProcessor} from './webhook-processor.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 signedEventRequest(input: {
26
+ receivedAt: string;
27
+ timestamp?: number;
28
+ }): ReturnType<typeof createStoredWebhookRequest> {
29
+ const body = Buffer.from(
30
+ JSON.stringify({
31
+ type: 'event_callback',
32
+ team_id: 'T1',
33
+ api_app_id: 'A1',
34
+ event: {type: 'app_mention', channel: 'C1', user: 'U1', text: 'hello', ts: '1.0'},
35
+ event_id: 'Ev-delayed',
36
+ event_time: 1_721_300_000,
37
+ }),
38
+ );
39
+ const timestamp = `${input.timestamp ?? Math.floor(new Date(input.receivedAt).getTime() / 1000)}`;
40
+ const signature = `v0=${createHmac('sha256', signingSecret)
41
+ .update(`v0:${timestamp}:`)
42
+ .update(body)
43
+ .digest('hex')}`;
44
+ return createStoredWebhookRequest({
45
+ requestId: randomUUID(),
46
+ routeId: 'slack.event',
47
+ receivedAt: input.receivedAt,
48
+ rawQueryString: '',
49
+ headers: {
50
+ 'content-type': 'application/json',
51
+ 'x-slack-request-timestamp': timestamp,
52
+ 'x-slack-signature': signature,
53
+ },
54
+ body,
55
+ });
56
+ }
57
+
58
+ describe('Slack webhook processor', () => {
59
+ beforeEach(async () => {
60
+ await db().delete(slackInstallations);
61
+ });
62
+
63
+ it('accepts an event delayed after a valid receipt', async () => {
64
+ const connection = fakeConnection();
65
+ const receivedAt = new Date(Date.now() - 10 * 60_000).toISOString();
66
+ await upsertSlackInstallation({
67
+ connectionId: connection.id,
68
+ teamId: 'T1',
69
+ teamName: 'Acme',
70
+ appId: 'A1',
71
+ botUserId: 'UBOT',
72
+ scopes: [],
73
+ status: 'installed',
74
+ });
75
+ const publishIntegrationEventReceived = vi.fn(() => Promise.resolve({published: true}));
76
+ const processor = createSlackWebhookProcessor({
77
+ coreDb: db,
78
+ claimWebhookDelivery: vi.fn(() => Promise.resolve({claimed: true})),
79
+ publishIntegrationEventReceived,
80
+ recordDeliveryOnly: vi.fn(() => Promise.resolve()),
81
+ getIntegrationConnectionById: vi.fn(() => Promise.resolve(connection)),
82
+ });
83
+
84
+ const result = await processor.process(signedEventRequest({receivedAt}));
85
+
86
+ expect(result).toMatchObject({outcome: 'processed', deliveryId: 'Ev-delayed'});
87
+ expect(publishIntegrationEventReceived).toHaveBeenCalledTimes(1);
88
+ });
89
+
90
+ it('discards an event stale at receipt', async () => {
91
+ const receivedAt = new Date().toISOString();
92
+ const processor = createSlackWebhookProcessor({
93
+ coreDb: db,
94
+ claimWebhookDelivery: vi.fn(() => Promise.resolve({claimed: true})),
95
+ publishIntegrationEventReceived: vi.fn(() => Promise.resolve({published: true})),
96
+ recordDeliveryOnly: vi.fn(() => Promise.resolve()),
97
+ getIntegrationConnectionById: vi.fn(),
98
+ });
99
+
100
+ const result = await processor.process(
101
+ signedEventRequest({receivedAt, timestamp: Math.floor(Date.now() / 1000) - 301}),
102
+ );
103
+
104
+ expect(result).toMatchObject({outcome: 'discarded', reason: 'stale_at_receipt'});
105
+ });
106
+ });