From 6e790303dffadda96f025ccf546292007d6b31f2 Mon Sep 17 00:00:00 2001 From: Colin Date: Mon, 16 Feb 2026 13:04:42 -0500 Subject: [PATCH] Slack: validate runtime blocks in send and edit paths --- src/slack/actions.blocks.test.ts | 78 ++++++++++++++++++++++++++++++++ src/slack/actions.ts | 4 +- src/slack/blocks-input.ts | 8 +++- src/slack/send.blocks.test.ts | 37 +++++++++++++++ src/slack/send.ts | 3 +- 5 files changed, 126 insertions(+), 4 deletions(-) create mode 100644 src/slack/actions.blocks.test.ts diff --git a/src/slack/actions.blocks.test.ts b/src/slack/actions.blocks.test.ts new file mode 100644 index 000000000..3746b5c41 --- /dev/null +++ b/src/slack/actions.blocks.test.ts @@ -0,0 +1,78 @@ +import type { WebClient } from "@slack/web-api"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("../config/config.js", () => ({ + loadConfig: () => ({}), +})); + +vi.mock("./accounts.js", () => ({ + resolveSlackAccount: () => ({ + accountId: "default", + botToken: "xoxb-test", + botTokenSource: "config", + config: {}, + }), +})); + +const { editSlackMessage } = await import("./actions.js"); + +function createClient() { + return { + chat: { + update: vi.fn(async () => ({ ok: true })), + }, + } as unknown as WebClient & { + chat: { + update: ReturnType; + }; + }; +} + +describe("editSlackMessage blocks", () => { + it("updates with valid blocks", async () => { + const client = createClient(); + + await editSlackMessage("C123", "171234.567", "", { + token: "xoxb-test", + client, + blocks: [{ type: "divider" }], + }); + + expect(client.chat.update).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "C123", + ts: "171234.567", + text: " ", + blocks: [{ type: "divider" }], + }), + ); + }); + + it("rejects empty blocks arrays", async () => { + const client = createClient(); + + await expect( + editSlackMessage("C123", "171234.567", "updated", { + token: "xoxb-test", + client, + blocks: [], + }), + ).rejects.toThrow(/must contain at least one block/i); + + expect(client.chat.update).not.toHaveBeenCalled(); + }); + + it("rejects blocks missing a type", async () => { + const client = createClient(); + + await expect( + editSlackMessage("C123", "171234.567", "updated", { + token: "xoxb-test", + client, + blocks: [{} as { type: string }], + }), + ).rejects.toThrow(/non-empty string type/i); + + expect(client.chat.update).not.toHaveBeenCalled(); + }); +}); diff --git a/src/slack/actions.ts b/src/slack/actions.ts index f503d5ef4..080467da0 100644 --- a/src/slack/actions.ts +++ b/src/slack/actions.ts @@ -2,6 +2,7 @@ import type { Block, KnownBlock, WebClient } from "@slack/web-api"; import { loadConfig } from "../config/config.js"; import { logVerbose } from "../globals.js"; import { resolveSlackAccount } from "./accounts.js"; +import { validateSlackBlocksArray } from "./blocks-input.js"; import { createSlackWebClient } from "./client.js"; import { sendMessageSlack } from "./send.js"; import { resolveSlackBotToken } from "./token.js"; @@ -170,11 +171,12 @@ export async function editSlackMessage( opts: SlackActionClientOpts & { blocks?: (Block | KnownBlock)[] } = {}, ) { const client = await getClient(opts); + const blocks = opts.blocks == null ? undefined : validateSlackBlocksArray(opts.blocks); await client.chat.update({ channel: channelId, ts: messageId, text: content || " ", - ...(opts.blocks?.length ? { blocks: opts.blocks } : {}), + ...(blocks ? { blocks } : {}), }); } diff --git a/src/slack/blocks-input.ts b/src/slack/blocks-input.ts index 1d8b61c2b..33056182a 100644 --- a/src/slack/blocks-input.ts +++ b/src/slack/blocks-input.ts @@ -31,11 +31,15 @@ function assertBlocksArray(raw: unknown) { } } +export function validateSlackBlocksArray(raw: unknown): (Block | KnownBlock)[] { + assertBlocksArray(raw); + return raw as (Block | KnownBlock)[]; +} + export function parseSlackBlocksInput(raw: unknown): (Block | KnownBlock)[] | undefined { if (raw == null) { return undefined; } const parsed = typeof raw === "string" ? parseBlocksJson(raw) : raw; - assertBlocksArray(parsed); - return parsed as (Block | KnownBlock)[]; + return validateSlackBlocksArray(parsed); } diff --git a/src/slack/send.blocks.test.ts b/src/slack/send.blocks.test.ts index 1b64ee5ff..c84c9bf4a 100644 --- a/src/slack/send.blocks.test.ts +++ b/src/slack/send.blocks.test.ts @@ -62,4 +62,41 @@ describe("sendMessageSlack blocks", () => { ).rejects.toThrow(/does not support blocks with mediaUrl/i); expect(client.chat.postMessage).not.toHaveBeenCalled(); }); + + it("rejects empty blocks arrays from runtime callers", async () => { + const client = createClient(); + await expect( + sendMessageSlack("channel:C123", "hi", { + token: "xoxb-test", + client, + blocks: [], + }), + ).rejects.toThrow(/must contain at least one block/i); + expect(client.chat.postMessage).not.toHaveBeenCalled(); + }); + + it("rejects blocks arrays above Slack max count", async () => { + const client = createClient(); + const blocks = Array.from({ length: 51 }, () => ({ type: "divider" })); + await expect( + sendMessageSlack("channel:C123", "hi", { + token: "xoxb-test", + client, + blocks, + }), + ).rejects.toThrow(/cannot exceed 50 items/i); + expect(client.chat.postMessage).not.toHaveBeenCalled(); + }); + + it("rejects blocks missing type from runtime callers", async () => { + const client = createClient(); + await expect( + sendMessageSlack("channel:C123", "hi", { + token: "xoxb-test", + client, + blocks: [{} as { type: string }], + }), + ).rejects.toThrow(/non-empty string type/i); + expect(client.chat.postMessage).not.toHaveBeenCalled(); + }); }); diff --git a/src/slack/send.ts b/src/slack/send.ts index 733baa0d3..eafd3d47b 100644 --- a/src/slack/send.ts +++ b/src/slack/send.ts @@ -15,6 +15,7 @@ import { resolveMarkdownTableMode } from "../config/markdown-tables.js"; import { logVerbose } from "../globals.js"; import { loadWebMedia } from "../web/media.js"; import { resolveSlackAccount } from "./accounts.js"; +import { validateSlackBlocksArray } from "./blocks-input.js"; import { createSlackWebClient } from "./client.js"; import { markdownToSlackMrkdwnChunks } from "./format.js"; import { parseSlackTarget } from "./targets.js"; @@ -222,7 +223,7 @@ export async function sendMessageSlack( opts: SlackSendOpts = {}, ): Promise { const trimmedMessage = message?.trim() ?? ""; - const blocks = opts.blocks?.length ? opts.blocks : undefined; + const blocks = opts.blocks == null ? undefined : validateSlackBlocksArray(opts.blocks); if (!trimmedMessage && !opts.mediaUrl && !blocks) { throw new Error("Slack send requires text, blocks, or media"); }