@crewhaus/channel-adapter-slack 0.1.4 → 0.1.5

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/src/index.test.ts DELETED
@@ -1,683 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { readFileSync } from "node:fs";
3
- import { join } from "node:path";
4
- import { createSlackAdapter, signSlackBody, verifySlackSignature } from "./index";
5
-
6
- // `tsc -b` also compiles this file into `dist/`; resolve fixtures from the
7
- // source tree so both the src and dist test copies find them.
8
- const FIXTURES = join(import.meta.dir.replace(/([/\\])dist$/, "$1src"), "fixtures");
9
- const APP_MENTION = readFileSync(join(FIXTURES, "app_mention.json"), "utf8");
10
- const MESSAGE = readFileSync(join(FIXTURES, "message.json"), "utf8");
11
- const BOT_MESSAGE = readFileSync(join(FIXTURES, "bot_message.json"), "utf8");
12
- const URL_VERIFICATION = readFileSync(join(FIXTURES, "url_verification.json"), "utf8");
13
-
14
- const SECRET = "test-secret-1234567890";
15
-
16
- function signedHeaders(body: string, secret: string, ts: number = Math.floor(Date.now() / 1000)) {
17
- const headers = new Headers();
18
- headers.set("x-slack-request-timestamp", String(ts));
19
- headers.set("x-slack-signature", signSlackBody({ body, timestamp: ts, signingSecret: secret }));
20
- return headers;
21
- }
22
-
23
- describe("verifySlackSignature (T8 — security)", () => {
24
- test("accepts a freshly signed request", () => {
25
- const body = APP_MENTION;
26
- const headers = signedHeaders(body, SECRET);
27
- expect(verifySlackSignature({ headers, body, signingSecret: SECRET })).toBe(true);
28
- });
29
-
30
- test("rejects a request with a tampered body", () => {
31
- const headers = signedHeaders(APP_MENTION, SECRET);
32
- const tamperedBody = APP_MENTION.replace("what time is it?", "rm -rf");
33
- expect(verifySlackSignature({ headers, body: tamperedBody, signingSecret: SECRET })).toBe(
34
- false,
35
- );
36
- });
37
-
38
- test("rejects a request with a tampered signature", () => {
39
- const body = APP_MENTION;
40
- const headers = signedHeaders(body, SECRET);
41
- headers.set(
42
- "x-slack-signature",
43
- `${headers.get("x-slack-signature")?.slice(0, -2) ?? "v0="}00`,
44
- );
45
- expect(verifySlackSignature({ headers, body, signingSecret: SECRET })).toBe(false);
46
- });
47
-
48
- test("rejects a request signed with a different secret", () => {
49
- const body = APP_MENTION;
50
- const headers = signedHeaders(body, "wrong-secret");
51
- expect(verifySlackSignature({ headers, body, signingSecret: SECRET })).toBe(false);
52
- });
53
-
54
- test("rejects a request with an expired timestamp (replay attack)", () => {
55
- const body = APP_MENTION;
56
- const oldTs = Math.floor(Date.now() / 1000) - 10 * 60; // 10 minutes ago
57
- const headers = signedHeaders(body, SECRET, oldTs);
58
- expect(verifySlackSignature({ headers, body, signingSecret: SECRET })).toBe(false);
59
- });
60
-
61
- test("rejects a request with a future timestamp", () => {
62
- const body = APP_MENTION;
63
- const futureTs = Math.floor(Date.now() / 1000) + 10 * 60;
64
- const headers = signedHeaders(body, SECRET, futureTs);
65
- expect(verifySlackSignature({ headers, body, signingSecret: SECRET })).toBe(false);
66
- });
67
-
68
- test("rejects a request with missing timestamp header", () => {
69
- const body = APP_MENTION;
70
- const headers = signedHeaders(body, SECRET);
71
- headers.delete("x-slack-request-timestamp");
72
- expect(verifySlackSignature({ headers, body, signingSecret: SECRET })).toBe(false);
73
- });
74
-
75
- test("rejects a request with missing signature header", () => {
76
- const body = APP_MENTION;
77
- const headers = signedHeaders(body, SECRET);
78
- headers.delete("x-slack-signature");
79
- expect(verifySlackSignature({ headers, body, signingSecret: SECRET })).toBe(false);
80
- });
81
-
82
- test("rejects a request with a malformed (non-numeric) timestamp", () => {
83
- const body = APP_MENTION;
84
- const headers = signedHeaders(body, SECRET);
85
- headers.set("x-slack-request-timestamp", "not-a-number");
86
- expect(verifySlackSignature({ headers, body, signingSecret: SECRET })).toBe(false);
87
- });
88
-
89
- test("rejects a request whose signature has the wrong length (timing-safe equal guard)", () => {
90
- const body = APP_MENTION;
91
- const headers = signedHeaders(body, SECRET);
92
- headers.set("x-slack-signature", "v0=short");
93
- expect(verifySlackSignature({ headers, body, signingSecret: SECRET })).toBe(false);
94
- });
95
-
96
- test("honours an injected `now` for tolerance testing", () => {
97
- const body = APP_MENTION;
98
- const fixedTs = 1700000000;
99
- const headers = signedHeaders(body, SECRET, fixedTs);
100
- const stillFresh = verifySlackSignature(
101
- { headers, body, signingSecret: SECRET },
102
- { now: () => fixedTs * 1000 + 60_000 },
103
- );
104
- expect(stillFresh).toBe(true);
105
- const stale = verifySlackSignature(
106
- { headers, body, signingSecret: SECRET },
107
- { now: () => fixedTs * 1000 + 10 * 60_000 },
108
- );
109
- expect(stale).toBe(false);
110
- });
111
- });
112
-
113
- describe("createSlackAdapter — parseInbound (T2 — contract)", () => {
114
- function adapter() {
115
- return createSlackAdapter({ botToken: "xoxb-test", signingSecret: SECRET });
116
- }
117
-
118
- test("parses app_mention into a normalised InboundEvent", () => {
119
- const out = adapter().parseInbound({ headers: new Headers(), body: APP_MENTION });
120
- expect(out.kind).toBe("event");
121
- if (out.kind !== "event") return;
122
- expect(out.event.subtype).toBe("app_mention");
123
- expect(out.event.workspaceId).toBe("T12345WRK");
124
- expect(out.event.channelId).toBe("C0CHAN01");
125
- expect(out.event.userId).toBe("U07USER01");
126
- expect(out.event.text).toBe("<@U0BOT> what time is it?");
127
- expect(out.event.threadTs).toBe("1700000000.000100");
128
- expect(out.event.idempotencyKey).toBe("Ev0EVENTONE");
129
- });
130
-
131
- test("parses a top-level message event", () => {
132
- const out = adapter().parseInbound({ headers: new Headers(), body: MESSAGE });
133
- expect(out.kind).toBe("event");
134
- if (out.kind !== "event") return;
135
- expect(out.event.subtype).toBe("message");
136
- expect(out.event.threadTs).toBeUndefined();
137
- expect(out.event.text).toBe("ping");
138
- });
139
-
140
- test("skips bot self-mentions (subtype: bot_message)", () => {
141
- const out = adapter().parseInbound({ headers: new Headers(), body: BOT_MESSAGE });
142
- expect(out.kind).toBe("skip");
143
- });
144
-
145
- test("returns a challenge for url_verification", () => {
146
- const out = adapter().parseInbound({ headers: new Headers(), body: URL_VERIFICATION });
147
- expect(out.kind).toBe("challenge");
148
- if (out.kind !== "challenge") return;
149
- expect(out.challenge.length).toBeGreaterThan(0);
150
- });
151
-
152
- test("skips unparsable JSON", () => {
153
- const out = adapter().parseInbound({ headers: new Headers(), body: "not-json" });
154
- expect(out.kind).toBe("skip");
155
- });
156
-
157
- test("skips events outside the {app_mention, message} set", () => {
158
- const body = JSON.stringify({
159
- type: "event_callback",
160
- team_id: "T1",
161
- event_id: "E1",
162
- event: { type: "channel_created", channel: "C1", user: "U1", ts: "1.0" },
163
- });
164
- const out = adapter().parseInbound({ headers: new Headers(), body });
165
- expect(out.kind).toBe("skip");
166
- });
167
-
168
- test("skips events with another bot's bot_id when selfBotId is unspecified", () => {
169
- const body = JSON.stringify({
170
- type: "event_callback",
171
- team_id: "T1",
172
- event_id: "E1",
173
- event: {
174
- type: "message",
175
- bot_id: "B_OTHER",
176
- text: "hi",
177
- channel: "C1",
178
- user: "U1",
179
- ts: "1.0",
180
- },
181
- });
182
- const out = adapter().parseInbound({ headers: new Headers(), body });
183
- expect(out.kind).toBe("skip");
184
- });
185
- });
186
-
187
- describe("createSlackAdapter — sendReply", () => {
188
- test("POSTs chat.postMessage with channel/thread_ts/text", async () => {
189
- const captured: Array<{ url: string; body: string; auth: string }> = [];
190
- const fakeFetch: typeof fetch = (async (input: string | URL | Request, init?: RequestInit) => {
191
- const url = typeof input === "string" ? input : input.toString();
192
- captured.push({
193
- url,
194
- body: typeof init?.body === "string" ? init.body : "",
195
- auth: (init?.headers as Record<string, string>)["Authorization"] ?? "",
196
- });
197
- return new Response(JSON.stringify({ ok: true }), {
198
- status: 200,
199
- headers: { "content-type": "application/json" },
200
- });
201
- }) as unknown as typeof fetch;
202
-
203
- const adapter = createSlackAdapter(
204
- { botToken: "xoxb-rotated", signingSecret: SECRET },
205
- { apiBaseUrl: "https://slack.test/api", fetch: fakeFetch },
206
- );
207
-
208
- await adapter.sendReply({
209
- event: {
210
- idempotencyKey: "E1",
211
- workspaceId: "T1",
212
- channelId: "C1",
213
- userId: "U1",
214
- threadTs: "1.0",
215
- ts: "1.1",
216
- text: "hi",
217
- subtype: "app_mention",
218
- },
219
- text: "hello back",
220
- });
221
-
222
- expect(captured.length).toBe(1);
223
- expect(captured[0]?.url).toBe("https://slack.test/api/chat.postMessage");
224
- expect(captured[0]?.auth).toBe("Bearer xoxb-rotated");
225
- const body = JSON.parse(captured[0]?.body ?? "{}");
226
- expect(body.channel).toBe("C1");
227
- expect(body.thread_ts).toBe("1.0");
228
- expect(body.text).toBe("hello back");
229
- });
230
-
231
- test("falls back to event.ts when no threadTs is set", async () => {
232
- let captured: { thread_ts?: string } = {};
233
- const fakeFetch: typeof fetch = (async (_input: string | URL | Request, init?: RequestInit) => {
234
- captured = JSON.parse(typeof init?.body === "string" ? init.body : "{}");
235
- return new Response(JSON.stringify({ ok: true }), {
236
- status: 200,
237
- headers: { "content-type": "application/json" },
238
- });
239
- }) as unknown as typeof fetch;
240
-
241
- const adapter = createSlackAdapter(
242
- { botToken: "xoxb", signingSecret: SECRET },
243
- { fetch: fakeFetch },
244
- );
245
- await adapter.sendReply({
246
- event: {
247
- idempotencyKey: "E",
248
- workspaceId: "T",
249
- channelId: "C",
250
- userId: "U",
251
- ts: "9.9",
252
- text: "x",
253
- subtype: "message",
254
- },
255
- text: "out",
256
- });
257
- expect(captured.thread_ts).toBe("9.9");
258
- });
259
-
260
- test("throws on Slack ok:false response", async () => {
261
- const fakeFetch: typeof fetch = (async () =>
262
- new Response(JSON.stringify({ ok: false, error: "channel_not_found" }), {
263
- status: 200,
264
- headers: { "content-type": "application/json" },
265
- })) as unknown as typeof fetch;
266
-
267
- const adapter = createSlackAdapter(
268
- { botToken: "xoxb", signingSecret: SECRET },
269
- { fetch: fakeFetch },
270
- );
271
- await expect(
272
- adapter.sendReply({
273
- event: {
274
- idempotencyKey: "E",
275
- workspaceId: "T",
276
- channelId: "C",
277
- userId: "U",
278
- ts: "1.0",
279
- text: "x",
280
- subtype: "message",
281
- },
282
- text: "x",
283
- }),
284
- ).rejects.toThrow(/channel_not_found/);
285
- });
286
- });
287
-
288
- describe("react (Phase 3 §3.2)", () => {
289
- const sampleEvent = {
290
- idempotencyKey: "E1",
291
- workspaceId: "T1",
292
- channelId: "C1",
293
- userId: "U1",
294
- ts: "1234.5678",
295
- text: "hello",
296
- subtype: "message" as const,
297
- };
298
-
299
- test("posts to reactions.add with channel + timestamp + emoji name", async () => {
300
- let capturedUrl = "";
301
- let capturedBody = "";
302
- const fakeFetch: typeof fetch = async (url, init) => {
303
- capturedUrl = String(url);
304
- capturedBody = String(init?.body ?? "");
305
- return new Response(JSON.stringify({ ok: true }), {
306
- status: 200,
307
- headers: { "content-type": "application/json" },
308
- });
309
- };
310
- const adapter = createSlackAdapter(
311
- { botToken: "xoxb-test", signingSecret: SECRET },
312
- { fetch: fakeFetch },
313
- );
314
- expect(adapter.react).toBeDefined();
315
- await adapter.react?.({ event: sampleEvent, emoji: "eyes" });
316
- expect(capturedUrl).toBe("https://slack.com/api/reactions.add");
317
- const body = JSON.parse(capturedBody) as { channel: string; timestamp: string; name: string };
318
- expect(body.channel).toBe("C1");
319
- expect(body.timestamp).toBe("1234.5678");
320
- expect(body.name).toBe("eyes");
321
- });
322
-
323
- test("strips leading/trailing colons from emoji name", async () => {
324
- let capturedBody = "";
325
- const fakeFetch: typeof fetch = async (_url, init) => {
326
- capturedBody = String(init?.body ?? "");
327
- return new Response(JSON.stringify({ ok: true }), { status: 200 });
328
- };
329
- const adapter = createSlackAdapter(
330
- { botToken: "xoxb", signingSecret: SECRET },
331
- { fetch: fakeFetch },
332
- );
333
- await adapter.react?.({ event: sampleEvent, emoji: ":white_check_mark:" });
334
- const body = JSON.parse(capturedBody) as { name: string };
335
- expect(body.name).toBe("white_check_mark");
336
- });
337
-
338
- test("treats already_reacted as benign no-op", async () => {
339
- const fakeFetch: typeof fetch = async () =>
340
- new Response(JSON.stringify({ ok: false, error: "already_reacted" }), {
341
- status: 200,
342
- headers: { "content-type": "application/json" },
343
- });
344
- const adapter = createSlackAdapter(
345
- { botToken: "xoxb", signingSecret: SECRET },
346
- { fetch: fakeFetch },
347
- );
348
- await expect(adapter.react?.({ event: sampleEvent, emoji: "eyes" })).resolves.toBeUndefined();
349
- });
350
-
351
- test("throws on non-already_reacted error", async () => {
352
- const fakeFetch: typeof fetch = async () =>
353
- new Response(JSON.stringify({ ok: false, error: "invalid_auth" }), {
354
- status: 200,
355
- headers: { "content-type": "application/json" },
356
- });
357
- const adapter = createSlackAdapter(
358
- { botToken: "xoxb", signingSecret: SECRET },
359
- { fetch: fakeFetch },
360
- );
361
- await expect(adapter.react?.({ event: sampleEvent, emoji: "eyes" })).rejects.toThrow(
362
- /invalid_auth/,
363
- );
364
- });
365
-
366
- test("does not parse a non-JSON reactions.add body (skips content-type guard)", async () => {
367
- // No content-type header => the `ct.includes("application/json")` branch is
368
- // false and the body is never read; an ok:false JSON payload would
369
- // otherwise throw, so reaching `resolves` proves the branch was skipped.
370
- const fakeFetch: typeof fetch = async () =>
371
- new Response(JSON.stringify({ ok: false, error: "invalid_auth" }), { status: 200 });
372
- const adapter = createSlackAdapter(
373
- { botToken: "xoxb", signingSecret: SECRET },
374
- { fetch: fakeFetch },
375
- );
376
- await expect(adapter.react?.({ event: sampleEvent, emoji: "eyes" })).resolves.toBeUndefined();
377
- });
378
-
379
- test("throws on a non-OK reactions.add HTTP status", async () => {
380
- const fakeFetch: typeof fetch = async () =>
381
- new Response("nope", { status: 500, statusText: "Internal Server Error" });
382
- const adapter = createSlackAdapter(
383
- { botToken: "xoxb", signingSecret: SECRET },
384
- { fetch: fakeFetch },
385
- );
386
- await expect(adapter.react?.({ event: sampleEvent, emoji: "eyes" })).rejects.toThrow(
387
- /reactions\.add failed: 500/,
388
- );
389
- });
390
- });
391
-
392
- describe("createSlackAdapter — verify (adapter method wiring)", () => {
393
- test("delegates to verifySlackSignature and accepts a fresh request", () => {
394
- const body = APP_MENTION;
395
- const headers = signedHeaders(body, SECRET);
396
- const adapter = createSlackAdapter({ botToken: "xoxb", signingSecret: SECRET });
397
- expect(adapter.verify({ headers, body })).toBe(true);
398
- });
399
-
400
- test("rejects a request signed with the wrong secret", () => {
401
- const body = APP_MENTION;
402
- const headers = signedHeaders(body, "some-other-secret");
403
- const adapter = createSlackAdapter({ botToken: "xoxb", signingSecret: SECRET });
404
- expect(adapter.verify({ headers, body })).toBe(false);
405
- });
406
-
407
- test("forwards an injected `now` so tolerance is deterministic", () => {
408
- const body = APP_MENTION;
409
- const fixedTs = 1700000000;
410
- const headers = signedHeaders(body, SECRET, fixedTs);
411
- // `now` far outside the ±5 min window => stale => false, proving the
412
- // `opts.now !== undefined` branch threads the clock through to verify.
413
- const adapter = createSlackAdapter(
414
- { botToken: "xoxb", signingSecret: SECRET },
415
- { now: () => fixedTs * 1000 + 60 * 60_000 },
416
- );
417
- expect(adapter.verify({ headers, body })).toBe(false);
418
- });
419
- });
420
-
421
- describe("security regressions (T8 — hardening)", () => {
422
- // parseInt is lenient and stops at the first non-digit, so a header like
423
- // "1700000000junk" parses to a fresh-looking 1700000000 for the replay-window
424
- // check. This must NOT be a bypass: the *raw* header string is bound into the
425
- // HMAC base (`v0:${timestamp}:${body}`), so a signature minted for the clean
426
- // timestamp cannot validate against the tampered one — and vice-versa.
427
- test("a parseInt-lenient timestamp header cannot be swapped past the HMAC", () => {
428
- const body = APP_MENTION;
429
- const ts = 1700000000;
430
- const sig = signSlackBody({ body, timestamp: ts, signingSecret: SECRET });
431
-
432
- const headers = new Headers();
433
- // Attacker keeps the timestamp inside the replay window numerically but
434
- // appends garbage, hoping parseInt-leniency lets it slide.
435
- headers.set("x-slack-request-timestamp", `${ts}junk`);
436
- headers.set("x-slack-signature", sig);
437
-
438
- const within = () => ts * 1000 + 1000; // numerically fresh
439
- expect(verifySlackSignature({ headers, body, signingSecret: SECRET }, { now: within })).toBe(
440
- false,
441
- );
442
- });
443
-
444
- // The signing secret is the only thing protecting the webhook; confirm that
445
- // forging a signature without it is impossible even with a known-good base.
446
- test("a signature computed under a different secret never validates", () => {
447
- const body = MESSAGE;
448
- const ts = 1700000001;
449
- const headers = new Headers();
450
- headers.set("x-slack-request-timestamp", String(ts));
451
- headers.set(
452
- "x-slack-signature",
453
- signSlackBody({ body, timestamp: ts, signingSecret: "attacker-secret" }),
454
- );
455
- expect(
456
- verifySlackSignature({ headers, body, signingSecret: SECRET }, { now: () => ts * 1000 }),
457
- ).toBe(false);
458
- });
459
-
460
- // parseInbound walks attacker-controlled JSON. A payload carrying a
461
- // "__proto__" key (or polluted nested object) must not mutate Object.prototype
462
- // nor leak through as a usable event.
463
- test("parseInbound does not pollute Object.prototype from a hostile payload", () => {
464
- const adapter = createSlackAdapter({ botToken: "xoxb", signingSecret: SECRET });
465
- const hostile =
466
- '{"type":"event_callback","__proto__":{"polluted":true},"event":{"type":"message","__proto__":{"polluted":true}}}';
467
- const out = adapter.parseInbound({ headers: new Headers(), body: hostile });
468
- // Missing required ids => skip, and crucially no prototype mutation.
469
- expect(out.kind).toBe("skip");
470
- expect(({} as Record<string, unknown>)["polluted"]).toBeUndefined();
471
- expect((Object.prototype as Record<string, unknown>)["polluted"]).toBeUndefined();
472
- });
473
-
474
- // A non-string text field must coerce to "" rather than leaking a non-string
475
- // (or object) into the normalised event the gateway trusts.
476
- test("parseInbound coerces a non-string text to empty string", () => {
477
- const adapter = createSlackAdapter({ botToken: "xoxb", signingSecret: SECRET });
478
- const body = JSON.stringify({
479
- type: "event_callback",
480
- team_id: "T1",
481
- event_id: "E1",
482
- event: {
483
- type: "message",
484
- channel: "C1",
485
- user: "U1",
486
- ts: "1.0",
487
- text: { nested: "object" },
488
- },
489
- });
490
- const out = adapter.parseInbound({ headers: new Headers(), body });
491
- expect(out.kind).toBe("event");
492
- if (out.kind !== "event") return;
493
- expect(out.event.text).toBe("");
494
- });
495
- });
496
-
497
- describe("createSlackAdapter — parseInbound selfBotId branches", () => {
498
- function bodyWithBot(botId: string) {
499
- return JSON.stringify({
500
- type: "event_callback",
501
- team_id: "T1",
502
- event_id: "E1",
503
- event: {
504
- type: "message",
505
- bot_id: botId,
506
- text: "hi",
507
- channel: "C1",
508
- user: "U1",
509
- ts: "1.0",
510
- },
511
- });
512
- }
513
-
514
- test("skips a message authored by our own bot (bot_id === selfBotId)", () => {
515
- const adapter = createSlackAdapter(
516
- { botToken: "xoxb", signingSecret: SECRET },
517
- { selfBotId: "B_SELF" },
518
- );
519
- const out = adapter.parseInbound({ headers: new Headers(), body: bodyWithBot("B_SELF") });
520
- expect(out.kind).toBe("skip");
521
- });
522
-
523
- test("lets another bot's message through when selfBotId is set", () => {
524
- const adapter = createSlackAdapter(
525
- { botToken: "xoxb", signingSecret: SECRET },
526
- { selfBotId: "B_SELF" },
527
- );
528
- const out = adapter.parseInbound({ headers: new Headers(), body: bodyWithBot("B_OTHER") });
529
- expect(out.kind).toBe("event");
530
- if (out.kind !== "event") return;
531
- expect(out.event.workspaceId).toBe("T1");
532
- expect(out.event.userId).toBe("U1");
533
- });
534
- });
535
-
536
- describe("createSlackAdapter — sendReply edge cases", () => {
537
- const sampleEvent = {
538
- idempotencyKey: "E",
539
- workspaceId: "T",
540
- channelId: "C",
541
- userId: "U",
542
- ts: "1.0",
543
- text: "x",
544
- subtype: "message" as const,
545
- };
546
-
547
- test("throws on a non-OK HTTP status from chat.postMessage", async () => {
548
- const fakeFetch: typeof fetch = async () =>
549
- new Response("boom", { status: 502, statusText: "Bad Gateway" });
550
- const adapter = createSlackAdapter(
551
- { botToken: "xoxb", signingSecret: SECRET },
552
- { fetch: fakeFetch },
553
- );
554
- await expect(adapter.sendReply({ event: sampleEvent, text: "x" })).rejects.toThrow(
555
- /chat\.postMessage failed: 502 Bad Gateway/,
556
- );
557
- });
558
-
559
- test("does not parse a non-JSON chat.postMessage body (skips content-type guard)", async () => {
560
- // ok:false in the body would throw if parsed; no content-type header means
561
- // the guard is skipped, so a resolved promise proves the branch was not taken.
562
- const fakeFetch: typeof fetch = async () =>
563
- new Response(JSON.stringify({ ok: false, error: "channel_not_found" }), { status: 200 });
564
- const adapter = createSlackAdapter(
565
- { botToken: "xoxb", signingSecret: SECRET },
566
- { fetch: fakeFetch },
567
- );
568
- await expect(adapter.sendReply({ event: sampleEvent, text: "x" })).resolves.toBeUndefined();
569
- });
570
-
571
- test("accepts a JSON ok:true response without throwing", async () => {
572
- const fakeFetch: typeof fetch = async () =>
573
- new Response(JSON.stringify({ ok: true }), {
574
- status: 200,
575
- headers: { "content-type": "application/json; charset=utf-8" },
576
- });
577
- const adapter = createSlackAdapter(
578
- { botToken: "xoxb", signingSecret: SECRET },
579
- { fetch: fakeFetch },
580
- );
581
- await expect(adapter.sendReply({ event: sampleEvent, text: "x" })).resolves.toBeUndefined();
582
- });
583
- });
584
-
585
- describe("createSlackAdapter — setTyping is a no-op", () => {
586
- test("resolves to undefined without performing any I/O", async () => {
587
- let called = false;
588
- const fakeFetch: typeof fetch = (async () => {
589
- called = true;
590
- return new Response("", { status: 200 });
591
- }) as unknown as typeof fetch;
592
- const adapter = createSlackAdapter(
593
- { botToken: "xoxb", signingSecret: SECRET },
594
- { fetch: fakeFetch },
595
- );
596
- await expect(
597
- adapter.setTyping({
598
- event: {
599
- idempotencyKey: "E",
600
- workspaceId: "T",
601
- channelId: "C",
602
- userId: "U",
603
- ts: "1.0",
604
- text: "x",
605
- subtype: "message",
606
- },
607
- }),
608
- ).resolves.toBeUndefined();
609
- expect(called).toBe(false);
610
- });
611
- });
612
-
613
- describe("createSlackAdapter — apiBaseUrl resolution", () => {
614
- test("falls back to SLACK_API_BASE_URL env when no opt is given", async () => {
615
- const prev = process.env["SLACK_API_BASE_URL"];
616
- process.env["SLACK_API_BASE_URL"] = "https://env.slack.test/api";
617
- try {
618
- let capturedUrl = "";
619
- const fakeFetch: typeof fetch = async (url) => {
620
- capturedUrl = String(url);
621
- return new Response(JSON.stringify({ ok: true }), {
622
- status: 200,
623
- headers: { "content-type": "application/json" },
624
- });
625
- };
626
- const adapter = createSlackAdapter(
627
- { botToken: "xoxb", signingSecret: SECRET },
628
- { fetch: fakeFetch },
629
- );
630
- await adapter.sendReply({
631
- event: {
632
- idempotencyKey: "E",
633
- workspaceId: "T",
634
- channelId: "C",
635
- userId: "U",
636
- ts: "1.0",
637
- text: "x",
638
- subtype: "message",
639
- },
640
- text: "out",
641
- });
642
- expect(capturedUrl).toBe("https://env.slack.test/api/chat.postMessage");
643
- } finally {
644
- if (prev === undefined) Reflect.deleteProperty(process.env, "SLACK_API_BASE_URL");
645
- else process.env["SLACK_API_BASE_URL"] = prev;
646
- }
647
- });
648
-
649
- test("an explicit apiBaseUrl opt takes precedence over the env var", async () => {
650
- const prev = process.env["SLACK_API_BASE_URL"];
651
- process.env["SLACK_API_BASE_URL"] = "https://env.slack.test/api";
652
- try {
653
- let capturedUrl = "";
654
- const fakeFetch: typeof fetch = async (url) => {
655
- capturedUrl = String(url);
656
- return new Response(JSON.stringify({ ok: true }), {
657
- status: 200,
658
- headers: { "content-type": "application/json" },
659
- });
660
- };
661
- const adapter = createSlackAdapter(
662
- { botToken: "xoxb", signingSecret: SECRET },
663
- { apiBaseUrl: "https://opt.slack.test/api", fetch: fakeFetch },
664
- );
665
- await adapter.sendReply({
666
- event: {
667
- idempotencyKey: "E",
668
- workspaceId: "T",
669
- channelId: "C",
670
- userId: "U",
671
- ts: "1.0",
672
- text: "x",
673
- subtype: "message",
674
- },
675
- text: "out",
676
- });
677
- expect(capturedUrl).toBe("https://opt.slack.test/api/chat.postMessage");
678
- } finally {
679
- if (prev === undefined) Reflect.deleteProperty(process.env, "SLACK_API_BASE_URL");
680
- else process.env["SLACK_API_BASE_URL"] = prev;
681
- }
682
- });
683
- });