@crewhaus/channel-adapter-slack 0.1.0 → 0.1.2

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 (2) hide show
  1. package/package.json +6 -11
  2. 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.0",
3
+ "version": "0.1.2",
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.0.0"
15
+ "@crewhaus/errors": "0.1.2"
16
16
  },
17
17
  "license": "Apache-2.0",
18
18
  "author": {
19
19
  "name": "Max Meier",
20
- "email": "max@studiomax.io",
21
- "url": "https://studiomax.io"
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": "restricted"
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
  });