Slack: validate runtime blocks in send and edit paths

This commit is contained in:
Colin
2026-02-16 13:04:42 -05:00
committed by Peter Steinberger
parent c01c6b7079
commit 6e790303df
5 changed files with 126 additions and 4 deletions

View File

@@ -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<typeof vi.fn>;
};
};
}
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();
});
});

View File

@@ -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 } : {}),
});
}

View File

@@ -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);
}

View File

@@ -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();
});
});

View File

@@ -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<SlackSendResult> {
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");
}