@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,16 @@
1
+ CREATE TABLE "integrations_slack_installations" (
2
+ "id" uuid PRIMARY KEY DEFAULT uuidv7() NOT NULL,
3
+ "connection_id" uuid NOT NULL,
4
+ "team_id" text NOT NULL,
5
+ "team_name" text NOT NULL,
6
+ "app_id" text NOT NULL,
7
+ "bot_user_id" text NOT NULL,
8
+ "scopes" jsonb NOT NULL,
9
+ "status" text NOT NULL,
10
+ "token_expires_at" timestamp with time zone,
11
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
12
+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL
13
+ );
14
+ --> statement-breakpoint
15
+ CREATE UNIQUE INDEX "integrations_slack_installations_connection_unique" ON "integrations_slack_installations" USING btree ("connection_id");--> statement-breakpoint
16
+ CREATE UNIQUE INDEX "integrations_slack_installations_team_unique" ON "integrations_slack_installations" USING btree ("team_id");
@@ -0,0 +1,132 @@
1
+ {
2
+ "id": "62801d4c-2399-4306-8d37-9773d10cbb02",
3
+ "prevId": "00000000-0000-0000-0000-000000000000",
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
+ "token_expires_at": {
61
+ "name": "token_expires_at",
62
+ "type": "timestamp with time zone",
63
+ "primaryKey": false,
64
+ "notNull": false
65
+ },
66
+ "created_at": {
67
+ "name": "created_at",
68
+ "type": "timestamp with time zone",
69
+ "primaryKey": false,
70
+ "notNull": true,
71
+ "default": "now()"
72
+ },
73
+ "updated_at": {
74
+ "name": "updated_at",
75
+ "type": "timestamp with time zone",
76
+ "primaryKey": false,
77
+ "notNull": true,
78
+ "default": "now()"
79
+ }
80
+ },
81
+ "indexes": {
82
+ "integrations_slack_installations_connection_unique": {
83
+ "name": "integrations_slack_installations_connection_unique",
84
+ "columns": [
85
+ {
86
+ "expression": "connection_id",
87
+ "isExpression": false,
88
+ "asc": true,
89
+ "nulls": "last"
90
+ }
91
+ ],
92
+ "isUnique": true,
93
+ "concurrently": false,
94
+ "method": "btree",
95
+ "with": {}
96
+ },
97
+ "integrations_slack_installations_team_unique": {
98
+ "name": "integrations_slack_installations_team_unique",
99
+ "columns": [
100
+ {
101
+ "expression": "team_id",
102
+ "isExpression": false,
103
+ "asc": true,
104
+ "nulls": "last"
105
+ }
106
+ ],
107
+ "isUnique": true,
108
+ "concurrently": false,
109
+ "method": "btree",
110
+ "with": {}
111
+ }
112
+ },
113
+ "foreignKeys": {},
114
+ "compositePrimaryKeys": {},
115
+ "uniqueConstraints": {},
116
+ "policies": {},
117
+ "checkConstraints": {},
118
+ "isRLSEnabled": false
119
+ }
120
+ },
121
+ "enums": {},
122
+ "schemas": {},
123
+ "sequences": {},
124
+ "roles": {},
125
+ "policies": {},
126
+ "views": {},
127
+ "_meta": {
128
+ "columns": {},
129
+ "schemas": {},
130
+ "tables": {}
131
+ }
132
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "version": "7",
3
+ "dialect": "postgresql",
4
+ "entries": [
5
+ {
6
+ "idx": 0,
7
+ "version": "7",
8
+ "when": 1784372558063,
9
+ "tag": "0000_calm_fantastic_four",
10
+ "breakpoints": true
11
+ }
12
+ ]
13
+ }
@@ -0,0 +1,7 @@
1
+ import {defineConfig} from 'drizzle-kit';
2
+
3
+ export default defineConfig({
4
+ schema: './src/db/schema/*.ts',
5
+ out: './drizzle',
6
+ dialect: 'postgresql',
7
+ });
package/package.json ADDED
@@ -0,0 +1,71 @@
1
+ {
2
+ "name": "@shipfox/api-integration-slack",
3
+ "license": "MIT",
4
+ "version": "4.0.0",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
8
+ "directory": "libs/api/integration/slack"
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
+ "drizzle-orm": "^0.45.2",
36
+ "ky": "^2.0.0",
37
+ "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"
50
+ },
51
+ "devDependencies": {
52
+ "@types/pg": "^8.15.5",
53
+ "drizzle-kit": "^0.31.10",
54
+ "fastify": "^5.3.3",
55
+ "fishery": "^2.4.0",
56
+ "@shipfox/swc": "1.2.5",
57
+ "@shipfox/biome": "1.8.1",
58
+ "@shipfox/ts-config": "1.3.8",
59
+ "@shipfox/typescript": "1.1.6",
60
+ "@shipfox/vitest": "1.2.2"
61
+ },
62
+ "scripts": {
63
+ "build": "shipfox-swc",
64
+ "check": "shipfox-biome-check",
65
+ "check:fix": "shipfox-biome-check --write",
66
+ "test": "shipfox-vitest-run",
67
+ "test:watch": "shipfox-vitest-watch",
68
+ "type": "shipfox-tsc-check",
69
+ "type:emit": "shipfox-tsc-emit"
70
+ }
71
+ }
@@ -0,0 +1,239 @@
1
+ import {HTTPError, TimeoutError} from 'ky';
2
+ import {
3
+ SlackEnterpriseInstallUnsupportedError,
4
+ SlackIntegrationProviderError,
5
+ SlackTokenRotationUnsupportedError,
6
+ } from '#core/errors.js';
7
+ import {createSlackApiClient} from './client.js';
8
+
9
+ const mocks = vi.hoisted(() => ({post: vi.fn(), warn: vi.fn()}));
10
+
11
+ vi.mock('@shipfox/node-opentelemetry', () => ({logger: () => ({warn: mocks.warn})}));
12
+ vi.mock('ky', () => {
13
+ class HTTPError extends Error {
14
+ constructor(public response: {status: number; statusText?: string; headers: Headers}) {
15
+ super('http');
16
+ this.name = 'HTTPError';
17
+ }
18
+ }
19
+ class TimeoutError extends Error {
20
+ constructor() {
21
+ super('timeout');
22
+ this.name = 'TimeoutError';
23
+ }
24
+ }
25
+ return {default: {post: mocks.post}, HTTPError, TimeoutError};
26
+ });
27
+
28
+ function resolves(data: unknown) {
29
+ return {json: () => Promise.resolve(data)};
30
+ }
31
+
32
+ function rejects(error: unknown) {
33
+ return {json: () => Promise.reject(error)};
34
+ }
35
+
36
+ function httpError(status: number, headers: Record<string, string> = {}): HTTPError {
37
+ return new HTTPError(
38
+ {status, statusText: 'Rejected', headers: new Headers(headers)} as never,
39
+ {} as never,
40
+ {} as never,
41
+ );
42
+ }
43
+
44
+ function oauthSuccess(overrides: Record<string, unknown> = {}) {
45
+ return {
46
+ ok: true,
47
+ access_token: 'xoxb-token',
48
+ bot_user_id: 'U123',
49
+ app_id: 'A123',
50
+ team: {id: 'T123', name: 'Acme'},
51
+ scope: 'app_mentions:read,chat:write',
52
+ ...overrides,
53
+ };
54
+ }
55
+
56
+ describe('createSlackApiClient', () => {
57
+ beforeEach(() => {
58
+ mocks.post.mockReset();
59
+ mocks.warn.mockReset();
60
+ });
61
+
62
+ it('posts a form-encoded OAuth exchange and parses the Slack installation', async () => {
63
+ mocks.post.mockReturnValue(resolves(oauthSuccess()));
64
+
65
+ const result = await createSlackApiClient().exchangeAuthorizationCode({code: 'oauth-code'});
66
+
67
+ const [url, options] = mocks.post.mock.calls[0] as [string, {body: URLSearchParams}];
68
+ expect(url).toBe('http://127.0.0.1:0/oauth.v2.access');
69
+ expect(options.body).toEqual(
70
+ new URLSearchParams({
71
+ client_id: 'test-client-id',
72
+ client_secret: 'test-client-secret',
73
+ code: 'oauth-code',
74
+ redirect_uri: 'https://shipfox.example.com/integrations/slack/callback',
75
+ }),
76
+ );
77
+ expect(result).toEqual({
78
+ accessToken: 'xoxb-token',
79
+ botUserId: 'U123',
80
+ appId: 'A123',
81
+ teamId: 'T123',
82
+ teamName: 'Acme',
83
+ scopes: ['app_mentions:read', 'chat:write'],
84
+ });
85
+ });
86
+
87
+ it.each([
88
+ ['invalid_code', 'access-denied'],
89
+ ['service_unavailable', 'provider-unavailable'],
90
+ ['ratelimited', 'rate-limited'],
91
+ ])('maps Slack application error %s to %s', async (error, reason) => {
92
+ mocks.post.mockReturnValue(resolves({ok: false, error}));
93
+
94
+ const result = createSlackApiClient().exchangeAuthorizationCode({code: 'oauth-code'});
95
+
96
+ await expect(result).rejects.toMatchObject({reason});
97
+ });
98
+
99
+ it.each([
100
+ oauthSuccess({is_enterprise_install: true}),
101
+ oauthSuccess({team: null}),
102
+ ])('rejects unsupported Enterprise Grid installs', async (body) => {
103
+ mocks.post.mockReturnValue(resolves(body));
104
+
105
+ const result = createSlackApiClient().exchangeAuthorizationCode({code: 'oauth-code'});
106
+
107
+ await expect(result).rejects.toBeInstanceOf(SlackEnterpriseInstallUnsupportedError);
108
+ });
109
+
110
+ it.each([
111
+ oauthSuccess({expires_in: 43_200}),
112
+ oauthSuccess({refresh_token: 'xoxe-1-refresh'}),
113
+ ])('rejects rotation-enabled OAuth responses', async (body) => {
114
+ mocks.post.mockReturnValue(resolves(body));
115
+
116
+ const result = createSlackApiClient().exchangeAuthorizationCode({code: 'oauth-code'});
117
+
118
+ await expect(result).rejects.toBeInstanceOf(SlackTokenRotationUnsupportedError);
119
+ });
120
+
121
+ it('never embeds a rotating token value in the thrown error', async () => {
122
+ mocks.post.mockReturnValue(resolves(oauthSuccess({refresh_token: 'xoxe-secret-refresh'})));
123
+
124
+ const error = await createSlackApiClient()
125
+ .exchangeAuthorizationCode({code: 'oauth-code'})
126
+ .catch((thrown: unknown) => thrown);
127
+
128
+ expect(error).toBeInstanceOf(SlackTokenRotationUnsupportedError);
129
+ expect((error as Error).message).toBe('Slack token rotation is not supported');
130
+ expect(String(error)).not.toContain('xoxe-secret-refresh');
131
+ });
132
+
133
+ it.each([
134
+ [rejects(httpError(503)), 'provider-unavailable'],
135
+ [rejects(httpError(403)), 'access-denied'],
136
+ [rejects(new TimeoutError({} as never)), 'timeout'],
137
+ ])('maps transport failures without leaking OAuth secrets', async (response, reason) => {
138
+ mocks.post.mockReturnValue(response);
139
+
140
+ const error = await createSlackApiClient()
141
+ .exchangeAuthorizationCode({code: 'super-secret-code'})
142
+ .catch((thrown: unknown) => thrown);
143
+
144
+ expect(error).toMatchObject({reason});
145
+ expect(JSON.stringify([error, mocks.warn.mock.calls])).not.toContain('super-secret-code');
146
+ expect(JSON.stringify([error, mocks.warn.mock.calls])).not.toContain('test-client-secret');
147
+ });
148
+
149
+ it('rejects malformed success payloads', async () => {
150
+ mocks.post.mockReturnValue(resolves(oauthSuccess({access_token: undefined})));
151
+
152
+ const result = createSlackApiClient().exchangeAuthorizationCode({code: 'oauth-code'});
153
+
154
+ await expect(result).rejects.toBeInstanceOf(SlackIntegrationProviderError);
155
+ await expect(result).rejects.toMatchObject({reason: 'malformed-provider-response'});
156
+ });
157
+
158
+ it('revokes with a bearer token and never logs it', async () => {
159
+ mocks.post.mockResolvedValue(undefined);
160
+
161
+ await createSlackApiClient().revokeToken({token: 'secret-token'});
162
+
163
+ expect(mocks.post).toHaveBeenCalledWith('http://127.0.0.1:0/auth.revoke', {
164
+ headers: {authorization: 'Bearer secret-token'},
165
+ timeout: 10_000,
166
+ });
167
+ expect(JSON.stringify(mocks.warn.mock.calls)).not.toContain('secret-token');
168
+ });
169
+
170
+ it.each([
171
+ {ok: true, channel: {id: 'C123'}},
172
+ {ok: false, error: 'channel_not_found'},
173
+ ])('passes through Slack Web API response %o', async (body) => {
174
+ mocks.post.mockReturnValue(resolves(body));
175
+
176
+ const result = await createSlackApiClient().callMethod({
177
+ method: 'conversations.history',
178
+ token: 'xoxb-secret',
179
+ arguments: {channel: 'C123'},
180
+ });
181
+
182
+ const [url, options] = mocks.post.mock.calls[0] as [
183
+ string,
184
+ {headers: {authorization: string}; body: URLSearchParams},
185
+ ];
186
+ expect(url).toBe('http://127.0.0.1:0/conversations.history');
187
+ expect(options.headers).toEqual({authorization: 'Bearer xoxb-secret'});
188
+ expect(options.body).toEqual(new URLSearchParams({channel: 'C123'}));
189
+ expect(result).toEqual(body);
190
+ });
191
+
192
+ it('serializes Slack method arguments as form values and omits nullish values', async () => {
193
+ mocks.post.mockReturnValue(resolves({ok: true}));
194
+ const blocks = [{type: 'section', text: {type: 'plain_text', text: 'Hello'}}];
195
+
196
+ await createSlackApiClient().callMethod({
197
+ method: 'chat.postMessage',
198
+ token: 'xoxb-secret',
199
+ arguments: {
200
+ blocks,
201
+ limit: 30,
202
+ inclusive: false,
203
+ types: 'public_channel,private_channel',
204
+ oldest: null,
205
+ latest: undefined,
206
+ },
207
+ });
208
+
209
+ const [, options] = mocks.post.mock.calls[0] as [string, {body: URLSearchParams}];
210
+ expect(Object.fromEntries(options.body)).toEqual({
211
+ blocks: JSON.stringify(blocks),
212
+ limit: '30',
213
+ inclusive: 'false',
214
+ types: 'public_channel,private_channel',
215
+ });
216
+ expect(options.body.has('oldest')).toBe(false);
217
+ expect(options.body.has('latest')).toBe(false);
218
+ });
219
+
220
+ it.each([
221
+ [httpError(429, {'retry-after': '17'}), 'rate-limited', 17],
222
+ [httpError(413), 'content-too-large', undefined],
223
+ [httpError(404), 'malformed-provider-response', undefined],
224
+ ])('maps Web API HTTP failures to %s', async (transportError, reason, retryAfterSeconds) => {
225
+ mocks.post.mockReturnValue(rejects(transportError));
226
+
227
+ const error = await createSlackApiClient()
228
+ .callMethod({
229
+ method: 'chat.postMessage',
230
+ token: 'xoxb-super-secret',
231
+ arguments: {channel: 'C123', text: 'sensitive body'},
232
+ })
233
+ .catch((thrown: unknown) => thrown);
234
+
235
+ expect(error).toMatchObject({reason, retryAfterSeconds});
236
+ expect(JSON.stringify(mocks.warn.mock.calls)).not.toContain('xoxb-super-secret');
237
+ expect(JSON.stringify(mocks.warn.mock.calls)).not.toContain('sensitive body');
238
+ });
239
+ });