@crewhaus/channel-adapter-slack 0.1.1 → 0.1.3
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/package.json +6 -11
- package/src/index.test.ts +318 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crewhaus/channel-adapter-slack",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Slack channel adapter: webhook signature verification, event parsing, reply-to-thread (Section 12)",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -12,13 +12,13 @@
|
|
|
12
12
|
"test": "bun test src"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@crewhaus/errors": "0.1.
|
|
15
|
+
"@crewhaus/errors": "0.1.3"
|
|
16
16
|
},
|
|
17
17
|
"license": "Apache-2.0",
|
|
18
18
|
"author": {
|
|
19
19
|
"name": "Max Meier",
|
|
20
|
-
"email": "max@
|
|
21
|
-
"url": "https://
|
|
20
|
+
"email": "max@crewhaus.ai",
|
|
21
|
+
"url": "https://crewhaus.ai"
|
|
22
22
|
},
|
|
23
23
|
"repository": {
|
|
24
24
|
"type": "git",
|
|
@@ -30,12 +30,7 @@
|
|
|
30
30
|
"url": "https://github.com/crewhaus/factory/issues"
|
|
31
31
|
},
|
|
32
32
|
"publishConfig": {
|
|
33
|
-
"access": "
|
|
33
|
+
"access": "public"
|
|
34
34
|
},
|
|
35
|
-
"files": [
|
|
36
|
-
"src",
|
|
37
|
-
"README.md",
|
|
38
|
-
"LICENSE",
|
|
39
|
-
"NOTICE"
|
|
40
|
-
]
|
|
35
|
+
"files": ["src", "README.md", "LICENSE", "NOTICE"]
|
|
41
36
|
}
|
package/src/index.test.ts
CHANGED
|
@@ -362,4 +362,322 @@ describe("react (Phase 3 §3.2)", () => {
|
|
|
362
362
|
/invalid_auth/,
|
|
363
363
|
);
|
|
364
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
|
+
});
|
|
365
683
|
});
|