mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 05:21:23 +00:00
refactor(channels): dedupe transport and gateway test scaffolds
This commit is contained in:
@@ -38,14 +38,18 @@ describe("slack prepareSlackMessage inbound contract", () => {
|
||||
}
|
||||
});
|
||||
|
||||
function createDefaultSlackCtx() {
|
||||
const slackCtx = createSlackMonitorContext({
|
||||
cfg: {
|
||||
channels: { slack: { enabled: true } },
|
||||
} as OpenClawConfig,
|
||||
function createInboundSlackCtx(params: {
|
||||
cfg: OpenClawConfig;
|
||||
appClient?: App["client"];
|
||||
defaultRequireMention?: boolean;
|
||||
replyToMode?: "off" | "all";
|
||||
channelsConfig?: Record<string, { systemPrompt: string }>;
|
||||
}) {
|
||||
return createSlackMonitorContext({
|
||||
cfg: params.cfg,
|
||||
accountId: "default",
|
||||
botToken: "token",
|
||||
app: { client: {} } as App,
|
||||
app: { client: params.appClient ?? {} } as App,
|
||||
runtime: {} as RuntimeEnv,
|
||||
botUserId: "B1",
|
||||
teamId: "T1",
|
||||
@@ -58,12 +62,13 @@ describe("slack prepareSlackMessage inbound contract", () => {
|
||||
allowFrom: [],
|
||||
groupDmEnabled: true,
|
||||
groupDmChannels: [],
|
||||
defaultRequireMention: true,
|
||||
defaultRequireMention: params.defaultRequireMention ?? true,
|
||||
channelsConfig: params.channelsConfig,
|
||||
groupPolicy: "open",
|
||||
useAccessGroups: false,
|
||||
reactionMode: "off",
|
||||
reactionAllowlist: [],
|
||||
replyToMode: "off",
|
||||
replyToMode: params.replyToMode ?? "off",
|
||||
threadHistoryScope: "thread",
|
||||
threadInheritParent: false,
|
||||
slashCommand: {
|
||||
@@ -77,6 +82,14 @@ describe("slack prepareSlackMessage inbound contract", () => {
|
||||
mediaMaxBytes: 1024,
|
||||
removeAckAfterReply: false,
|
||||
});
|
||||
}
|
||||
|
||||
function createDefaultSlackCtx() {
|
||||
const slackCtx = createInboundSlackCtx({
|
||||
cfg: {
|
||||
channels: { slack: { enabled: true } },
|
||||
} as OpenClawConfig,
|
||||
});
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
slackCtx.resolveUserName = async () => ({ name: "Alice" }) as any;
|
||||
return slackCtx;
|
||||
@@ -100,41 +113,11 @@ describe("slack prepareSlackMessage inbound contract", () => {
|
||||
}
|
||||
|
||||
function createThreadSlackCtx(params: { cfg: OpenClawConfig; replies: unknown }) {
|
||||
return createSlackMonitorContext({
|
||||
return createInboundSlackCtx({
|
||||
cfg: params.cfg,
|
||||
accountId: "default",
|
||||
botToken: "token",
|
||||
app: { client: { conversations: { replies: params.replies } } } as App,
|
||||
runtime: {} as RuntimeEnv,
|
||||
botUserId: "B1",
|
||||
teamId: "T1",
|
||||
apiAppId: "A1",
|
||||
historyLimit: 0,
|
||||
sessionScope: "per-sender",
|
||||
mainKey: "main",
|
||||
dmEnabled: true,
|
||||
dmPolicy: "open",
|
||||
allowFrom: [],
|
||||
groupDmEnabled: true,
|
||||
groupDmChannels: [],
|
||||
appClient: { conversations: { replies: params.replies } } as App["client"],
|
||||
defaultRequireMention: false,
|
||||
groupPolicy: "open",
|
||||
useAccessGroups: false,
|
||||
reactionMode: "off",
|
||||
reactionAllowlist: [],
|
||||
replyToMode: "all",
|
||||
threadHistoryScope: "thread",
|
||||
threadInheritParent: false,
|
||||
slashCommand: {
|
||||
enabled: false,
|
||||
name: "openclaw",
|
||||
sessionPrefix: "slack:slash",
|
||||
ephemeral: true,
|
||||
},
|
||||
textLimit: 4000,
|
||||
ackReactionScope: "group-mentions",
|
||||
mediaMaxBytes: 1024,
|
||||
removeAckAfterReply: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -168,7 +151,7 @@ describe("slack prepareSlackMessage inbound contract", () => {
|
||||
});
|
||||
|
||||
it("keeps channel metadata out of GroupSystemPrompt", async () => {
|
||||
const slackCtx = createSlackMonitorContext({
|
||||
const slackCtx = createInboundSlackCtx({
|
||||
cfg: {
|
||||
channels: {
|
||||
slack: {
|
||||
@@ -176,42 +159,10 @@ describe("slack prepareSlackMessage inbound contract", () => {
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
accountId: "default",
|
||||
botToken: "token",
|
||||
app: { client: {} } as App,
|
||||
runtime: {} as RuntimeEnv,
|
||||
botUserId: "B1",
|
||||
teamId: "T1",
|
||||
apiAppId: "A1",
|
||||
historyLimit: 0,
|
||||
sessionScope: "per-sender",
|
||||
mainKey: "main",
|
||||
dmEnabled: true,
|
||||
dmPolicy: "open",
|
||||
allowFrom: [],
|
||||
groupDmEnabled: true,
|
||||
groupDmChannels: [],
|
||||
defaultRequireMention: false,
|
||||
channelsConfig: {
|
||||
C123: { systemPrompt: "Config prompt" },
|
||||
},
|
||||
groupPolicy: "open",
|
||||
useAccessGroups: false,
|
||||
reactionMode: "off",
|
||||
reactionAllowlist: [],
|
||||
replyToMode: "off",
|
||||
threadHistoryScope: "thread",
|
||||
threadInheritParent: false,
|
||||
slashCommand: {
|
||||
enabled: false,
|
||||
name: "openclaw",
|
||||
sessionPrefix: "slack:slash",
|
||||
ephemeral: true,
|
||||
},
|
||||
textLimit: 4000,
|
||||
ackReactionScope: "group-mentions",
|
||||
mediaMaxBytes: 1024,
|
||||
removeAckAfterReply: false,
|
||||
});
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
slackCtx.resolveUserName = async () => ({ name: "Alice" }) as any;
|
||||
@@ -256,43 +207,11 @@ describe("slack prepareSlackMessage inbound contract", () => {
|
||||
});
|
||||
|
||||
it("sets MessageThreadId for top-level messages when replyToMode=all", async () => {
|
||||
const slackCtx = createSlackMonitorContext({
|
||||
const slackCtx = createInboundSlackCtx({
|
||||
cfg: {
|
||||
channels: { slack: { enabled: true, replyToMode: "all" } },
|
||||
} as OpenClawConfig,
|
||||
accountId: "default",
|
||||
botToken: "token",
|
||||
app: { client: {} } as App,
|
||||
runtime: {} as RuntimeEnv,
|
||||
botUserId: "B1",
|
||||
teamId: "T1",
|
||||
apiAppId: "A1",
|
||||
historyLimit: 0,
|
||||
sessionScope: "per-sender",
|
||||
mainKey: "main",
|
||||
dmEnabled: true,
|
||||
dmPolicy: "open",
|
||||
allowFrom: [],
|
||||
groupDmEnabled: true,
|
||||
groupDmChannels: [],
|
||||
defaultRequireMention: true,
|
||||
groupPolicy: "open",
|
||||
useAccessGroups: false,
|
||||
reactionMode: "off",
|
||||
reactionAllowlist: [],
|
||||
replyToMode: "all",
|
||||
threadHistoryScope: "thread",
|
||||
threadInheritParent: false,
|
||||
slashCommand: {
|
||||
enabled: false,
|
||||
name: "openclaw",
|
||||
sessionPrefix: "slack:slash",
|
||||
ephemeral: true,
|
||||
},
|
||||
textLimit: 4000,
|
||||
ackReactionScope: "group-mentions",
|
||||
mediaMaxBytes: 1024,
|
||||
removeAckAfterReply: false,
|
||||
});
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
slackCtx.resolveUserName = async () => ({ name: "Alice" }) as any;
|
||||
@@ -487,11 +406,16 @@ describe("slack prepareSlackMessage inbound contract", () => {
|
||||
});
|
||||
|
||||
describe("prepareSlackMessage sender prefix", () => {
|
||||
it("prefixes channel bodies with sender label", async () => {
|
||||
const ctx = {
|
||||
function createSenderPrefixCtx(params: {
|
||||
channels: Record<string, unknown>;
|
||||
allowFrom?: string[];
|
||||
useAccessGroups?: boolean;
|
||||
slashCommand: Record<string, unknown>;
|
||||
}): SlackMonitorContext {
|
||||
return {
|
||||
cfg: {
|
||||
agents: { defaults: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/openclaw" } },
|
||||
channels: { slack: {} },
|
||||
channels: { slack: params.channels },
|
||||
},
|
||||
accountId: "default",
|
||||
botToken: "xoxb",
|
||||
@@ -512,18 +436,18 @@ describe("prepareSlackMessage sender prefix", () => {
|
||||
mainKey: "agent:main:main",
|
||||
dmEnabled: true,
|
||||
dmPolicy: "open",
|
||||
allowFrom: [],
|
||||
allowFrom: params.allowFrom ?? [],
|
||||
groupDmEnabled: false,
|
||||
groupDmChannels: [],
|
||||
defaultRequireMention: true,
|
||||
groupPolicy: "open",
|
||||
useAccessGroups: false,
|
||||
useAccessGroups: params.useAccessGroups ?? false,
|
||||
reactionMode: "off",
|
||||
reactionAllowlist: [],
|
||||
replyToMode: "off",
|
||||
threadHistoryScope: "channel",
|
||||
threadInheritParent: false,
|
||||
slashCommand: { command: "/openclaw", enabled: true },
|
||||
slashCommand: params.slashCommand,
|
||||
textLimit: 2000,
|
||||
ackReactionScope: "off",
|
||||
mediaMaxBytes: 1000,
|
||||
@@ -533,13 +457,17 @@ describe("prepareSlackMessage sender prefix", () => {
|
||||
shouldDropMismatchedSlackEvent: () => false,
|
||||
resolveSlackSystemEventSessionKey: () => "agent:main:slack:channel:c1",
|
||||
isChannelAllowed: () => true,
|
||||
resolveChannelName: async () => ({
|
||||
name: "general",
|
||||
type: "channel",
|
||||
}),
|
||||
resolveChannelName: async () => ({ name: "general", type: "channel" }),
|
||||
resolveUserName: async () => ({ name: "Alice" }),
|
||||
setSlackThreadStatus: async () => undefined,
|
||||
} satisfies SlackMonitorContext;
|
||||
} as unknown as SlackMonitorContext;
|
||||
}
|
||||
|
||||
it("prefixes channel bodies with sender label", async () => {
|
||||
const ctx = createSenderPrefixCtx({
|
||||
channels: {},
|
||||
slashCommand: { command: "/openclaw", enabled: true },
|
||||
});
|
||||
|
||||
const result = await prepareSlackMessage({
|
||||
ctx,
|
||||
@@ -562,60 +490,17 @@ describe("prepareSlackMessage sender prefix", () => {
|
||||
});
|
||||
|
||||
it("detects /new as control command when prefixed with Slack mention", async () => {
|
||||
const ctx = {
|
||||
cfg: {
|
||||
agents: { defaults: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/openclaw" } },
|
||||
channels: { slack: { dm: { enabled: true, policy: "open", allowFrom: ["*"] } } },
|
||||
},
|
||||
accountId: "default",
|
||||
botToken: "xoxb",
|
||||
app: { client: {} },
|
||||
runtime: {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: (code: number): never => {
|
||||
throw new Error(`exit ${code}`);
|
||||
},
|
||||
},
|
||||
botUserId: "BOT",
|
||||
teamId: "T1",
|
||||
apiAppId: "A1",
|
||||
historyLimit: 0,
|
||||
channelHistories: new Map(),
|
||||
sessionScope: "per-sender",
|
||||
mainKey: "agent:main:main",
|
||||
dmEnabled: true,
|
||||
dmPolicy: "open",
|
||||
const ctx = createSenderPrefixCtx({
|
||||
channels: { dm: { enabled: true, policy: "open", allowFrom: ["*"] } },
|
||||
allowFrom: ["U1"],
|
||||
groupDmEnabled: false,
|
||||
groupDmChannels: [],
|
||||
defaultRequireMention: true,
|
||||
groupPolicy: "open",
|
||||
useAccessGroups: true,
|
||||
reactionMode: "off",
|
||||
reactionAllowlist: [],
|
||||
replyToMode: "off",
|
||||
threadHistoryScope: "channel",
|
||||
threadInheritParent: false,
|
||||
slashCommand: {
|
||||
enabled: false,
|
||||
name: "openclaw",
|
||||
sessionPrefix: "slack:slash",
|
||||
ephemeral: true,
|
||||
},
|
||||
textLimit: 2000,
|
||||
ackReactionScope: "off",
|
||||
mediaMaxBytes: 1000,
|
||||
removeAckAfterReply: false,
|
||||
logger: { info: vi.fn(), warn: vi.fn() },
|
||||
markMessageSeen: () => false,
|
||||
shouldDropMismatchedSlackEvent: () => false,
|
||||
resolveSlackSystemEventSessionKey: () => "agent:main:slack:channel:c1",
|
||||
isChannelAllowed: () => true,
|
||||
resolveChannelName: async () => ({ name: "general", type: "channel" }),
|
||||
resolveUserName: async () => ({ name: "Alice" }),
|
||||
setSlackThreadStatus: async () => undefined,
|
||||
} satisfies SlackMonitorContext;
|
||||
});
|
||||
|
||||
const result = await prepareSlackMessage({
|
||||
ctx,
|
||||
|
||||
@@ -129,12 +129,19 @@ function createArgMenusHarness() {
|
||||
|
||||
describe("Slack native command argument menus", () => {
|
||||
let harness: ReturnType<typeof createArgMenusHarness>;
|
||||
let usageHandler: (args: unknown) => Promise<void>;
|
||||
let argMenuHandler: (args: unknown) => Promise<void>;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = createArgMenusHarness();
|
||||
await registerCommands(harness.ctx, harness.account);
|
||||
|
||||
const usage = harness.commands.get("/usage");
|
||||
if (!usage) {
|
||||
throw new Error("Missing /usage handler");
|
||||
}
|
||||
usageHandler = usage;
|
||||
|
||||
const argMenu = harness.actions.get("openclaw_cmdarg");
|
||||
if (!argMenu) {
|
||||
throw new Error("Missing arg-menu action handler");
|
||||
@@ -146,6 +153,29 @@ describe("Slack native command argument menus", () => {
|
||||
harness.postEphemeral.mockClear();
|
||||
});
|
||||
|
||||
it("shows a button menu when required args are omitted", async () => {
|
||||
const respond = vi.fn().mockResolvedValue(undefined);
|
||||
const ack = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
await usageHandler({
|
||||
command: {
|
||||
user_id: "U1",
|
||||
user_name: "Ada",
|
||||
channel_id: "C1",
|
||||
channel_name: "directmessage",
|
||||
text: "",
|
||||
trigger_id: "t1",
|
||||
},
|
||||
ack,
|
||||
respond,
|
||||
});
|
||||
|
||||
expect(respond).toHaveBeenCalledTimes(1);
|
||||
const payload = respond.mock.calls[0]?.[0] as { blocks?: Array<{ type: string }> };
|
||||
expect(payload.blocks?.[0]?.type).toBe("section");
|
||||
expect(payload.blocks?.[1]?.type).toBe("actions");
|
||||
});
|
||||
|
||||
it("dispatches the command when a menu button is clicked", async () => {
|
||||
const respond = vi.fn().mockResolvedValue(undefined);
|
||||
await argMenuHandler({
|
||||
@@ -187,4 +217,303 @@ describe("Slack native command argument menus", () => {
|
||||
response_type: "ephemeral",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to postEphemeral with token when respond is unavailable", async () => {
|
||||
await argMenuHandler({
|
||||
ack: vi.fn().mockResolvedValue(undefined),
|
||||
action: { value: "garbage" },
|
||||
body: { user: { id: "U1" }, channel: { id: "C1" } },
|
||||
});
|
||||
|
||||
expect(harness.postEphemeral).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
token: "bot-token",
|
||||
channel: "C1",
|
||||
user: "U1",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("treats malformed percent-encoding as an invalid button (no throw)", async () => {
|
||||
await argMenuHandler({
|
||||
ack: vi.fn().mockResolvedValue(undefined),
|
||||
action: { value: "cmdarg|%E0%A4%A|mode|on|U1" },
|
||||
body: { user: { id: "U1" }, channel: { id: "C1" } },
|
||||
});
|
||||
|
||||
expect(harness.postEphemeral).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
token: "bot-token",
|
||||
channel: "C1",
|
||||
user: "U1",
|
||||
text: "Sorry, that button is no longer valid.",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function createPolicyHarness(overrides?: {
|
||||
groupPolicy?: "open" | "allowlist";
|
||||
channelsConfig?: Record<string, { allow?: boolean; requireMention?: boolean }>;
|
||||
channelId?: string;
|
||||
channelName?: string;
|
||||
allowFrom?: string[];
|
||||
useAccessGroups?: boolean;
|
||||
resolveChannelName?: () => Promise<{ name?: string; type?: string }>;
|
||||
}) {
|
||||
const commands = new Map<unknown, (args: unknown) => Promise<void>>();
|
||||
const postEphemeral = vi.fn().mockResolvedValue({ ok: true });
|
||||
const app = {
|
||||
client: { chat: { postEphemeral } },
|
||||
command: (name: unknown, handler: (args: unknown) => Promise<void>) => {
|
||||
commands.set(name, handler);
|
||||
},
|
||||
};
|
||||
|
||||
const channelId = overrides?.channelId ?? "C_UNLISTED";
|
||||
const channelName = overrides?.channelName ?? "unlisted";
|
||||
|
||||
const ctx = {
|
||||
cfg: { commands: { native: false } },
|
||||
runtime: {},
|
||||
botToken: "bot-token",
|
||||
botUserId: "bot",
|
||||
teamId: "T1",
|
||||
allowFrom: overrides?.allowFrom ?? ["*"],
|
||||
dmEnabled: true,
|
||||
dmPolicy: "open",
|
||||
groupDmEnabled: false,
|
||||
groupDmChannels: [],
|
||||
defaultRequireMention: true,
|
||||
groupPolicy: overrides?.groupPolicy ?? "open",
|
||||
useAccessGroups: overrides?.useAccessGroups ?? true,
|
||||
channelsConfig: overrides?.channelsConfig,
|
||||
slashCommand: {
|
||||
enabled: true,
|
||||
name: "openclaw",
|
||||
ephemeral: true,
|
||||
sessionPrefix: "slack:slash",
|
||||
},
|
||||
textLimit: 4000,
|
||||
app,
|
||||
isChannelAllowed: () => true,
|
||||
resolveChannelName:
|
||||
overrides?.resolveChannelName ?? (async () => ({ name: channelName, type: "channel" })),
|
||||
resolveUserName: async () => ({ name: "Ada" }),
|
||||
} as unknown;
|
||||
|
||||
const account = { accountId: "acct", config: { commands: { native: false } } } as unknown;
|
||||
|
||||
return { commands, ctx, account, postEphemeral, channelId, channelName };
|
||||
}
|
||||
|
||||
async function runSlashHandler(params: {
|
||||
commands: Map<unknown, (args: unknown) => Promise<void>>;
|
||||
command: Partial<{
|
||||
user_id: string;
|
||||
user_name: string;
|
||||
channel_id: string;
|
||||
channel_name: string;
|
||||
text: string;
|
||||
trigger_id: string;
|
||||
}> &
|
||||
Pick<{ channel_id: string; channel_name: string }, "channel_id" | "channel_name">;
|
||||
}): Promise<{ respond: ReturnType<typeof vi.fn>; ack: ReturnType<typeof vi.fn> }> {
|
||||
const handler = [...params.commands.values()][0];
|
||||
if (!handler) {
|
||||
throw new Error("Missing slash handler");
|
||||
}
|
||||
|
||||
const respond = vi.fn().mockResolvedValue(undefined);
|
||||
const ack = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
await handler({
|
||||
command: {
|
||||
user_id: "U1",
|
||||
user_name: "Ada",
|
||||
text: "hello",
|
||||
trigger_id: "t1",
|
||||
...params.command,
|
||||
},
|
||||
ack,
|
||||
respond,
|
||||
});
|
||||
|
||||
return { respond, ack };
|
||||
}
|
||||
|
||||
function expectChannelBlockedResponse(respond: ReturnType<typeof vi.fn>) {
|
||||
expect(dispatchMock).not.toHaveBeenCalled();
|
||||
expect(respond).toHaveBeenCalledWith({
|
||||
text: "This channel is not allowed.",
|
||||
response_type: "ephemeral",
|
||||
});
|
||||
}
|
||||
|
||||
function expectUnauthorizedResponse(respond: ReturnType<typeof vi.fn>) {
|
||||
expect(dispatchMock).not.toHaveBeenCalled();
|
||||
expect(respond).toHaveBeenCalledWith({
|
||||
text: "You are not authorized to use this command.",
|
||||
response_type: "ephemeral",
|
||||
});
|
||||
}
|
||||
|
||||
describe("slack slash commands channel policy", () => {
|
||||
it("allows unlisted channels when groupPolicy is open", async () => {
|
||||
const { commands, ctx, account, channelId, channelName } = createPolicyHarness({
|
||||
groupPolicy: "open",
|
||||
channelsConfig: { C_LISTED: { requireMention: true } },
|
||||
channelId: "C_UNLISTED",
|
||||
channelName: "unlisted",
|
||||
});
|
||||
await registerCommands(ctx, account);
|
||||
|
||||
const { respond } = await runSlashHandler({
|
||||
commands,
|
||||
command: {
|
||||
channel_id: channelId,
|
||||
channel_name: channelName,
|
||||
},
|
||||
});
|
||||
|
||||
expect(dispatchMock).toHaveBeenCalledTimes(1);
|
||||
expect(respond).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ text: "This channel is not allowed." }),
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks explicitly denied channels when groupPolicy is open", async () => {
|
||||
const { commands, ctx, account, channelId, channelName } = createPolicyHarness({
|
||||
groupPolicy: "open",
|
||||
channelsConfig: { C_DENIED: { allow: false } },
|
||||
channelId: "C_DENIED",
|
||||
channelName: "denied",
|
||||
});
|
||||
await registerCommands(ctx, account);
|
||||
|
||||
const { respond } = await runSlashHandler({
|
||||
commands,
|
||||
command: {
|
||||
channel_id: channelId,
|
||||
channel_name: channelName,
|
||||
},
|
||||
});
|
||||
|
||||
expectChannelBlockedResponse(respond);
|
||||
});
|
||||
|
||||
it("blocks unlisted channels when groupPolicy is allowlist", async () => {
|
||||
const { commands, ctx, account, channelId, channelName } = createPolicyHarness({
|
||||
groupPolicy: "allowlist",
|
||||
channelsConfig: { C_LISTED: { requireMention: true } },
|
||||
channelId: "C_UNLISTED",
|
||||
channelName: "unlisted",
|
||||
});
|
||||
await registerCommands(ctx, account);
|
||||
|
||||
const { respond } = await runSlashHandler({
|
||||
commands,
|
||||
command: {
|
||||
channel_id: channelId,
|
||||
channel_name: channelName,
|
||||
},
|
||||
});
|
||||
|
||||
expectChannelBlockedResponse(respond);
|
||||
});
|
||||
});
|
||||
|
||||
describe("slack slash commands access groups", () => {
|
||||
it("fails closed when channel type lookup returns empty for channels", async () => {
|
||||
const { commands, ctx, account, channelId, channelName } = createPolicyHarness({
|
||||
allowFrom: [],
|
||||
channelId: "C_UNKNOWN",
|
||||
channelName: "unknown",
|
||||
resolveChannelName: async () => ({}),
|
||||
});
|
||||
await registerCommands(ctx, account);
|
||||
|
||||
const { respond } = await runSlashHandler({
|
||||
commands,
|
||||
command: {
|
||||
channel_id: channelId,
|
||||
channel_name: channelName,
|
||||
},
|
||||
});
|
||||
|
||||
expectUnauthorizedResponse(respond);
|
||||
});
|
||||
|
||||
it("still treats D-prefixed channel ids as DMs when lookup fails", async () => {
|
||||
const { commands, ctx, account } = createPolicyHarness({
|
||||
allowFrom: [],
|
||||
channelId: "D123",
|
||||
channelName: "notdirectmessage",
|
||||
resolveChannelName: async () => ({}),
|
||||
});
|
||||
await registerCommands(ctx, account);
|
||||
|
||||
const { respond } = await runSlashHandler({
|
||||
commands,
|
||||
command: {
|
||||
channel_id: "D123",
|
||||
channel_name: "notdirectmessage",
|
||||
},
|
||||
});
|
||||
|
||||
expect(dispatchMock).toHaveBeenCalledTimes(1);
|
||||
expect(respond).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ text: "You are not authorized to use this command." }),
|
||||
);
|
||||
const dispatchArg = dispatchMock.mock.calls[0]?.[0] as {
|
||||
ctx?: { CommandAuthorized?: boolean };
|
||||
};
|
||||
expect(dispatchArg?.ctx?.CommandAuthorized).toBe(false);
|
||||
});
|
||||
|
||||
it("computes CommandAuthorized for DM slash commands when dmPolicy is open", async () => {
|
||||
const { commands, ctx, account } = createPolicyHarness({
|
||||
allowFrom: ["U_OWNER"],
|
||||
channelId: "D999",
|
||||
channelName: "directmessage",
|
||||
resolveChannelName: async () => ({ name: "directmessage", type: "im" }),
|
||||
});
|
||||
await registerCommands(ctx, account);
|
||||
|
||||
await runSlashHandler({
|
||||
commands,
|
||||
command: {
|
||||
user_id: "U_ATTACKER",
|
||||
user_name: "Mallory",
|
||||
channel_id: "D999",
|
||||
channel_name: "directmessage",
|
||||
},
|
||||
});
|
||||
|
||||
expect(dispatchMock).toHaveBeenCalledTimes(1);
|
||||
const dispatchArg = dispatchMock.mock.calls[0]?.[0] as {
|
||||
ctx?: { CommandAuthorized?: boolean };
|
||||
};
|
||||
expect(dispatchArg?.ctx?.CommandAuthorized).toBe(false);
|
||||
});
|
||||
|
||||
it("enforces access-group gating when lookup fails for private channels", async () => {
|
||||
const { commands, ctx, account, channelId, channelName } = createPolicyHarness({
|
||||
allowFrom: [],
|
||||
channelId: "G123",
|
||||
channelName: "private",
|
||||
resolveChannelName: async () => ({}),
|
||||
});
|
||||
await registerCommands(ctx, account);
|
||||
|
||||
const { respond } = await runSlashHandler({
|
||||
commands,
|
||||
command: {
|
||||
channel_id: channelId,
|
||||
channel_name: channelName,
|
||||
},
|
||||
});
|
||||
|
||||
expectUnauthorizedResponse(respond);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { WebClient as SlackWebClient } from "@slack/web-api";
|
||||
import type { SlackMessageEvent } from "../types.js";
|
||||
import { logVerbose, shouldLogVerbose } from "../../globals.js";
|
||||
import { pruneMapToMaxSize } from "../../infra/map-size.js";
|
||||
|
||||
type ThreadTsCacheEntry = {
|
||||
threadTs: string | null;
|
||||
@@ -68,17 +69,7 @@ export function createSlackThreadTsResolver(params: {
|
||||
const setCached = (key: string, threadTs: string | null, now: number) => {
|
||||
cache.delete(key);
|
||||
cache.set(key, { threadTs, updatedAt: now });
|
||||
if (maxSize <= 0) {
|
||||
cache.clear();
|
||||
return;
|
||||
}
|
||||
while (cache.size > maxSize) {
|
||||
const oldestKey = cache.keys().next().value;
|
||||
if (!oldestKey) {
|
||||
break;
|
||||
}
|
||||
cache.delete(oldestKey);
|
||||
}
|
||||
pruneMapToMaxSize(cache, maxSize);
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user