mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-10 18:54:58 +00:00
test(integration): dedupe messaging, secrets, and plugin test suites
This commit is contained in:
@@ -21,6 +21,45 @@ function createClient() {
|
||||
};
|
||||
}
|
||||
|
||||
function makeSlackFileInfo(overrides?: Record<string, unknown>) {
|
||||
return {
|
||||
id: "F123",
|
||||
name: "image.png",
|
||||
mimetype: "image/png",
|
||||
url_private_download: "https://files.slack.com/files-pri/T1-F123/image.png",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeResolvedSlackMedia() {
|
||||
return {
|
||||
path: "/tmp/image.png",
|
||||
contentType: "image/png",
|
||||
placeholder: "[Slack file: image.png]",
|
||||
};
|
||||
}
|
||||
|
||||
function expectNoMediaDownload(result: Awaited<ReturnType<typeof downloadSlackFile>>) {
|
||||
expect(result).toBeNull();
|
||||
expect(resolveSlackMedia).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
function expectResolveSlackMediaCalledWithDefaults() {
|
||||
expect(resolveSlackMedia).toHaveBeenCalledWith({
|
||||
files: [
|
||||
{
|
||||
id: "F123",
|
||||
name: "image.png",
|
||||
mimetype: "image/png",
|
||||
url_private: undefined,
|
||||
url_private_download: "https://files.slack.com/files-pri/T1-F123/image.png",
|
||||
},
|
||||
],
|
||||
token: "xoxb-test",
|
||||
maxBytes: 1024,
|
||||
});
|
||||
}
|
||||
|
||||
describe("downloadSlackFile", () => {
|
||||
beforeEach(() => {
|
||||
resolveSlackMedia.mockReset();
|
||||
@@ -48,20 +87,9 @@ describe("downloadSlackFile", () => {
|
||||
it("downloads via resolveSlackMedia using fresh files.info metadata", async () => {
|
||||
const client = createClient();
|
||||
client.files.info.mockResolvedValueOnce({
|
||||
file: {
|
||||
id: "F123",
|
||||
name: "image.png",
|
||||
mimetype: "image/png",
|
||||
url_private_download: "https://files.slack.com/files-pri/T1-F123/image.png",
|
||||
},
|
||||
file: makeSlackFileInfo(),
|
||||
});
|
||||
resolveSlackMedia.mockResolvedValueOnce([
|
||||
{
|
||||
path: "/tmp/image.png",
|
||||
contentType: "image/png",
|
||||
placeholder: "[Slack file: image.png]",
|
||||
},
|
||||
]);
|
||||
resolveSlackMedia.mockResolvedValueOnce([makeResolvedSlackMedia()]);
|
||||
|
||||
const result = await downloadSlackFile("F123", {
|
||||
client,
|
||||
@@ -70,36 +98,14 @@ describe("downloadSlackFile", () => {
|
||||
});
|
||||
|
||||
expect(client.files.info).toHaveBeenCalledWith({ file: "F123" });
|
||||
expect(resolveSlackMedia).toHaveBeenCalledWith({
|
||||
files: [
|
||||
{
|
||||
id: "F123",
|
||||
name: "image.png",
|
||||
mimetype: "image/png",
|
||||
url_private: undefined,
|
||||
url_private_download: "https://files.slack.com/files-pri/T1-F123/image.png",
|
||||
},
|
||||
],
|
||||
token: "xoxb-test",
|
||||
maxBytes: 1024,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
path: "/tmp/image.png",
|
||||
contentType: "image/png",
|
||||
placeholder: "[Slack file: image.png]",
|
||||
});
|
||||
expectResolveSlackMediaCalledWithDefaults();
|
||||
expect(result).toEqual(makeResolvedSlackMedia());
|
||||
});
|
||||
|
||||
it("returns null when channel scope definitely mismatches file shares", async () => {
|
||||
const client = createClient();
|
||||
client.files.info.mockResolvedValueOnce({
|
||||
file: {
|
||||
id: "F123",
|
||||
name: "image.png",
|
||||
mimetype: "image/png",
|
||||
url_private_download: "https://files.slack.com/files-pri/T1-F123/image.png",
|
||||
channels: ["C999"],
|
||||
},
|
||||
file: makeSlackFileInfo({ channels: ["C999"] }),
|
||||
});
|
||||
|
||||
const result = await downloadSlackFile("F123", {
|
||||
@@ -109,24 +115,19 @@ describe("downloadSlackFile", () => {
|
||||
channelId: "C123",
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(resolveSlackMedia).not.toHaveBeenCalled();
|
||||
expectNoMediaDownload(result);
|
||||
});
|
||||
|
||||
it("returns null when thread scope definitely mismatches file share thread", async () => {
|
||||
const client = createClient();
|
||||
client.files.info.mockResolvedValueOnce({
|
||||
file: {
|
||||
id: "F123",
|
||||
name: "image.png",
|
||||
mimetype: "image/png",
|
||||
url_private_download: "https://files.slack.com/files-pri/T1-F123/image.png",
|
||||
file: makeSlackFileInfo({
|
||||
shares: {
|
||||
private: {
|
||||
C123: [{ ts: "111.111", thread_ts: "111.111" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await downloadSlackFile("F123", {
|
||||
@@ -137,27 +138,15 @@ describe("downloadSlackFile", () => {
|
||||
threadId: "222.222",
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(resolveSlackMedia).not.toHaveBeenCalled();
|
||||
expectNoMediaDownload(result);
|
||||
});
|
||||
|
||||
it("keeps legacy behavior when file metadata does not expose channel/thread shares", async () => {
|
||||
const client = createClient();
|
||||
client.files.info.mockResolvedValueOnce({
|
||||
file: {
|
||||
id: "F123",
|
||||
name: "image.png",
|
||||
mimetype: "image/png",
|
||||
url_private_download: "https://files.slack.com/files-pri/T1-F123/image.png",
|
||||
},
|
||||
file: makeSlackFileInfo(),
|
||||
});
|
||||
resolveSlackMedia.mockResolvedValueOnce([
|
||||
{
|
||||
path: "/tmp/image.png",
|
||||
contentType: "image/png",
|
||||
placeholder: "[Slack file: image.png]",
|
||||
},
|
||||
]);
|
||||
resolveSlackMedia.mockResolvedValueOnce([makeResolvedSlackMedia()]);
|
||||
|
||||
const result = await downloadSlackFile("F123", {
|
||||
client,
|
||||
@@ -167,11 +156,8 @@ describe("downloadSlackFile", () => {
|
||||
threadId: "222.222",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
path: "/tmp/image.png",
|
||||
contentType: "image/png",
|
||||
placeholder: "[Slack file: image.png]",
|
||||
});
|
||||
expect(result).toEqual(makeResolvedSlackMedia());
|
||||
expect(resolveSlackMedia).toHaveBeenCalledTimes(1);
|
||||
expectResolveSlackMediaCalledWithDefaults();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,44 +1,35 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { registerSlackMemberEvents } from "./members.js";
|
||||
import {
|
||||
createSlackSystemEventTestHarness,
|
||||
type SlackSystemEventTestOverrides,
|
||||
createSlackSystemEventTestHarness as initSlackHarness,
|
||||
type SlackSystemEventTestOverrides as MemberOverrides,
|
||||
} from "./system-event-test-harness.js";
|
||||
|
||||
const enqueueSystemEventMock = vi.fn();
|
||||
const readAllowFromStoreMock = vi.fn();
|
||||
const memberMocks = vi.hoisted(() => ({
|
||||
enqueue: vi.fn(),
|
||||
readAllow: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../../infra/system-events.js", () => ({
|
||||
enqueueSystemEvent: (...args: unknown[]) => enqueueSystemEventMock(...args),
|
||||
enqueueSystemEvent: memberMocks.enqueue,
|
||||
}));
|
||||
|
||||
vi.mock("../../../pairing/pairing-store.js", () => ({
|
||||
readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args),
|
||||
readChannelAllowFromStore: memberMocks.readAllow,
|
||||
}));
|
||||
|
||||
type SlackMemberHandler = (args: {
|
||||
event: Record<string, unknown>;
|
||||
body: unknown;
|
||||
}) => Promise<void>;
|
||||
type MemberHandler = (args: { event: Record<string, unknown>; body: unknown }) => Promise<void>;
|
||||
|
||||
function createMembersContext(params?: {
|
||||
overrides?: SlackSystemEventTestOverrides;
|
||||
type MemberCaseArgs = {
|
||||
event?: Record<string, unknown>;
|
||||
body?: unknown;
|
||||
overrides?: MemberOverrides;
|
||||
handler?: "joined" | "left";
|
||||
trackEvent?: () => void;
|
||||
shouldDropMismatchedSlackEvent?: (body: unknown) => boolean;
|
||||
}) {
|
||||
const harness = createSlackSystemEventTestHarness(params?.overrides);
|
||||
if (params?.shouldDropMismatchedSlackEvent) {
|
||||
harness.ctx.shouldDropMismatchedSlackEvent = params.shouldDropMismatchedSlackEvent;
|
||||
}
|
||||
registerSlackMemberEvents({ ctx: harness.ctx, trackEvent: params?.trackEvent });
|
||||
return {
|
||||
getJoinedHandler: () =>
|
||||
harness.getHandler("member_joined_channel") as SlackMemberHandler | null,
|
||||
getLeftHandler: () => harness.getHandler("member_left_channel") as SlackMemberHandler | null,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
function makeMemberEvent(overrides?: { user?: string; channel?: string }) {
|
||||
function makeMemberEvent(overrides?: { channel?: string; user?: string }) {
|
||||
return {
|
||||
type: "member_joined_channel",
|
||||
user: overrides?.user ?? "U1",
|
||||
@@ -47,106 +38,90 @@ function makeMemberEvent(overrides?: { user?: string; channel?: string }) {
|
||||
};
|
||||
}
|
||||
|
||||
function getMemberHandlers(params: {
|
||||
overrides?: MemberOverrides;
|
||||
trackEvent?: () => void;
|
||||
shouldDropMismatchedSlackEvent?: (body: unknown) => boolean;
|
||||
}) {
|
||||
const harness = initSlackHarness(params.overrides);
|
||||
if (params.shouldDropMismatchedSlackEvent) {
|
||||
harness.ctx.shouldDropMismatchedSlackEvent = params.shouldDropMismatchedSlackEvent;
|
||||
}
|
||||
registerSlackMemberEvents({ ctx: harness.ctx, trackEvent: params.trackEvent });
|
||||
return {
|
||||
joined: harness.getHandler("member_joined_channel") as MemberHandler | null,
|
||||
left: harness.getHandler("member_left_channel") as MemberHandler | null,
|
||||
};
|
||||
}
|
||||
|
||||
async function runMemberCase(args: MemberCaseArgs = {}): Promise<void> {
|
||||
memberMocks.enqueue.mockClear();
|
||||
memberMocks.readAllow.mockReset().mockResolvedValue([]);
|
||||
const handlers = getMemberHandlers({
|
||||
overrides: args.overrides,
|
||||
trackEvent: args.trackEvent,
|
||||
shouldDropMismatchedSlackEvent: args.shouldDropMismatchedSlackEvent,
|
||||
});
|
||||
const key = args.handler ?? "joined";
|
||||
const handler = handlers[key];
|
||||
expect(handler).toBeTruthy();
|
||||
await handler!({
|
||||
event: (args.event ?? makeMemberEvent()) as Record<string, unknown>,
|
||||
body: args.body ?? {},
|
||||
});
|
||||
}
|
||||
|
||||
describe("registerSlackMemberEvents", () => {
|
||||
it("enqueues DM member events when dmPolicy is open", async () => {
|
||||
enqueueSystemEventMock.mockClear();
|
||||
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
|
||||
const { getJoinedHandler } = createMembersContext({ overrides: { dmPolicy: "open" } });
|
||||
const joinedHandler = getJoinedHandler();
|
||||
expect(joinedHandler).toBeTruthy();
|
||||
|
||||
await joinedHandler!({
|
||||
event: makeMemberEvent(),
|
||||
body: {},
|
||||
});
|
||||
|
||||
expect(enqueueSystemEventMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("blocks DM member events when dmPolicy is disabled", async () => {
|
||||
enqueueSystemEventMock.mockClear();
|
||||
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
|
||||
const { getJoinedHandler } = createMembersContext({ overrides: { dmPolicy: "disabled" } });
|
||||
const joinedHandler = getJoinedHandler();
|
||||
expect(joinedHandler).toBeTruthy();
|
||||
|
||||
await joinedHandler!({
|
||||
event: makeMemberEvent(),
|
||||
body: {},
|
||||
});
|
||||
|
||||
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks DM member events for unauthorized senders in allowlist mode", async () => {
|
||||
enqueueSystemEventMock.mockClear();
|
||||
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
|
||||
const { getJoinedHandler } = createMembersContext({
|
||||
overrides: { dmPolicy: "allowlist", allowFrom: ["U2"] },
|
||||
});
|
||||
const joinedHandler = getJoinedHandler();
|
||||
expect(joinedHandler).toBeTruthy();
|
||||
|
||||
await joinedHandler!({
|
||||
event: makeMemberEvent({ user: "U1" }),
|
||||
body: {},
|
||||
});
|
||||
|
||||
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows DM member events for authorized senders in allowlist mode", async () => {
|
||||
enqueueSystemEventMock.mockClear();
|
||||
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
|
||||
const { getLeftHandler } = createMembersContext({
|
||||
overrides: { dmPolicy: "allowlist", allowFrom: ["U1"] },
|
||||
});
|
||||
const leftHandler = getLeftHandler();
|
||||
expect(leftHandler).toBeTruthy();
|
||||
|
||||
await leftHandler!({
|
||||
event: {
|
||||
...makeMemberEvent({ user: "U1" }),
|
||||
type: "member_left_channel",
|
||||
it.each([
|
||||
{
|
||||
name: "enqueues DM member events when dmPolicy is open",
|
||||
args: { overrides: { dmPolicy: "open" } },
|
||||
calls: 1,
|
||||
},
|
||||
{
|
||||
name: "blocks DM member events when dmPolicy is disabled",
|
||||
args: { overrides: { dmPolicy: "disabled" } },
|
||||
calls: 0,
|
||||
},
|
||||
{
|
||||
name: "blocks DM member events for unauthorized senders in allowlist mode",
|
||||
args: {
|
||||
overrides: { dmPolicy: "allowlist", allowFrom: ["U2"] },
|
||||
event: makeMemberEvent({ user: "U1" }),
|
||||
},
|
||||
body: {},
|
||||
});
|
||||
|
||||
expect(enqueueSystemEventMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("blocks channel member events for users outside channel users allowlist", async () => {
|
||||
enqueueSystemEventMock.mockClear();
|
||||
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
|
||||
const { getJoinedHandler } = createMembersContext({
|
||||
overrides: {
|
||||
dmPolicy: "open",
|
||||
channelType: "channel",
|
||||
channelUsers: ["U_OWNER"],
|
||||
calls: 0,
|
||||
},
|
||||
{
|
||||
name: "allows DM member events for authorized senders in allowlist mode",
|
||||
args: {
|
||||
handler: "left" as const,
|
||||
overrides: { dmPolicy: "allowlist", allowFrom: ["U1"] },
|
||||
event: { ...makeMemberEvent({ user: "U1" }), type: "member_left_channel" },
|
||||
},
|
||||
});
|
||||
const joinedHandler = getJoinedHandler();
|
||||
expect(joinedHandler).toBeTruthy();
|
||||
|
||||
await joinedHandler!({
|
||||
event: makeMemberEvent({ channel: "C1", user: "U_ATTACKER" }),
|
||||
body: {},
|
||||
});
|
||||
|
||||
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
|
||||
calls: 1,
|
||||
},
|
||||
{
|
||||
name: "blocks channel member events for users outside channel users allowlist",
|
||||
args: {
|
||||
overrides: {
|
||||
dmPolicy: "open",
|
||||
channelType: "channel",
|
||||
channelUsers: ["U_OWNER"],
|
||||
},
|
||||
event: makeMemberEvent({ channel: "C1", user: "U_ATTACKER" }),
|
||||
},
|
||||
calls: 0,
|
||||
},
|
||||
])("$name", async ({ args, calls }) => {
|
||||
await runMemberCase(args);
|
||||
expect(memberMocks.enqueue).toHaveBeenCalledTimes(calls);
|
||||
});
|
||||
|
||||
it("does not track mismatched events", async () => {
|
||||
const trackEvent = vi.fn();
|
||||
const { getJoinedHandler } = createMembersContext({
|
||||
await runMemberCase({
|
||||
trackEvent,
|
||||
shouldDropMismatchedSlackEvent: () => true,
|
||||
});
|
||||
const joinedHandler = getJoinedHandler();
|
||||
expect(joinedHandler).toBeTruthy();
|
||||
|
||||
await joinedHandler!({
|
||||
event: makeMemberEvent(),
|
||||
body: { api_app_id: "A_OTHER" },
|
||||
});
|
||||
|
||||
@@ -155,14 +130,7 @@ describe("registerSlackMemberEvents", () => {
|
||||
|
||||
it("tracks accepted member events", async () => {
|
||||
const trackEvent = vi.fn();
|
||||
const { getJoinedHandler } = createMembersContext({ trackEvent });
|
||||
const joinedHandler = getJoinedHandler();
|
||||
expect(joinedHandler).toBeTruthy();
|
||||
|
||||
await joinedHandler!({
|
||||
event: makeMemberEvent(),
|
||||
body: {},
|
||||
});
|
||||
await runMemberCase({ trackEvent });
|
||||
|
||||
expect(trackEvent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -5,23 +5,26 @@ import {
|
||||
type SlackSystemEventTestOverrides,
|
||||
} from "./system-event-test-harness.js";
|
||||
|
||||
const enqueueSystemEventMock = vi.fn();
|
||||
const readAllowFromStoreMock = vi.fn();
|
||||
const messageQueueMock = vi.fn();
|
||||
const messageAllowMock = vi.fn();
|
||||
|
||||
vi.mock("../../../infra/system-events.js", () => ({
|
||||
enqueueSystemEvent: (...args: unknown[]) => enqueueSystemEventMock(...args),
|
||||
enqueueSystemEvent: (...args: unknown[]) => messageQueueMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../../pairing/pairing-store.js", () => ({
|
||||
readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args),
|
||||
readChannelAllowFromStore: (...args: unknown[]) => messageAllowMock(...args),
|
||||
}));
|
||||
|
||||
type SlackMessageHandler = (args: {
|
||||
event: Record<string, unknown>;
|
||||
body: unknown;
|
||||
}) => Promise<void>;
|
||||
type MessageHandler = (args: { event: Record<string, unknown>; body: unknown }) => Promise<void>;
|
||||
|
||||
function createMessagesContext(overrides?: SlackSystemEventTestOverrides) {
|
||||
type MessageCase = {
|
||||
overrides?: SlackSystemEventTestOverrides;
|
||||
event?: Record<string, unknown>;
|
||||
body?: unknown;
|
||||
};
|
||||
|
||||
function createMessageHandlers(overrides?: SlackSystemEventTestOverrides) {
|
||||
const harness = createSlackSystemEventTestHarness(overrides);
|
||||
const handleSlackMessage = vi.fn(async () => {});
|
||||
registerSlackMessageEvents({
|
||||
@@ -29,7 +32,7 @@ function createMessagesContext(overrides?: SlackSystemEventTestOverrides) {
|
||||
handleSlackMessage,
|
||||
});
|
||||
return {
|
||||
getMessageHandler: () => harness.getHandler("message") as SlackMessageHandler | null,
|
||||
handler: harness.getHandler("message") as MessageHandler | null,
|
||||
handleSlackMessage,
|
||||
};
|
||||
}
|
||||
@@ -40,14 +43,8 @@ function makeChangedEvent(overrides?: { channel?: string; user?: string }) {
|
||||
type: "message",
|
||||
subtype: "message_changed",
|
||||
channel: overrides?.channel ?? "D1",
|
||||
message: {
|
||||
ts: "123.456",
|
||||
user,
|
||||
},
|
||||
previous_message: {
|
||||
ts: "123.450",
|
||||
user,
|
||||
},
|
||||
message: { ts: "123.456", user },
|
||||
previous_message: { ts: "123.450", user },
|
||||
event_ts: "123.456",
|
||||
};
|
||||
}
|
||||
@@ -73,113 +70,78 @@ function makeThreadBroadcastEvent(overrides?: { channel?: string; user?: string
|
||||
subtype: "thread_broadcast",
|
||||
channel: overrides?.channel ?? "D1",
|
||||
user,
|
||||
message: {
|
||||
ts: "123.456",
|
||||
user,
|
||||
},
|
||||
message: { ts: "123.456", user },
|
||||
event_ts: "123.456",
|
||||
};
|
||||
}
|
||||
|
||||
async function runMessageCase(input: MessageCase = {}): Promise<void> {
|
||||
messageQueueMock.mockClear();
|
||||
messageAllowMock.mockReset().mockResolvedValue([]);
|
||||
const { handler } = createMessageHandlers(input.overrides);
|
||||
expect(handler).toBeTruthy();
|
||||
await handler!({
|
||||
event: (input.event ?? makeChangedEvent()) as Record<string, unknown>,
|
||||
body: input.body ?? {},
|
||||
});
|
||||
}
|
||||
|
||||
describe("registerSlackMessageEvents", () => {
|
||||
it("enqueues message_changed system events when dmPolicy is open", async () => {
|
||||
enqueueSystemEventMock.mockClear();
|
||||
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
|
||||
const { getMessageHandler } = createMessagesContext({ dmPolicy: "open" });
|
||||
const messageHandler = getMessageHandler();
|
||||
expect(messageHandler).toBeTruthy();
|
||||
|
||||
await messageHandler!({
|
||||
event: makeChangedEvent(),
|
||||
body: {},
|
||||
});
|
||||
|
||||
expect(enqueueSystemEventMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("blocks message_changed system events when dmPolicy is disabled", async () => {
|
||||
enqueueSystemEventMock.mockClear();
|
||||
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
|
||||
const { getMessageHandler } = createMessagesContext({ dmPolicy: "disabled" });
|
||||
const messageHandler = getMessageHandler();
|
||||
expect(messageHandler).toBeTruthy();
|
||||
|
||||
await messageHandler!({
|
||||
event: makeChangedEvent(),
|
||||
body: {},
|
||||
});
|
||||
|
||||
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks message_changed system events for unauthorized senders in allowlist mode", async () => {
|
||||
enqueueSystemEventMock.mockClear();
|
||||
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
|
||||
const { getMessageHandler } = createMessagesContext({
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: ["U2"],
|
||||
});
|
||||
const messageHandler = getMessageHandler();
|
||||
expect(messageHandler).toBeTruthy();
|
||||
|
||||
await messageHandler!({
|
||||
event: makeChangedEvent({ user: "U1" }),
|
||||
body: {},
|
||||
});
|
||||
|
||||
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks message_deleted system events for users outside channel users allowlist", async () => {
|
||||
enqueueSystemEventMock.mockClear();
|
||||
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
|
||||
const { getMessageHandler } = createMessagesContext({
|
||||
dmPolicy: "open",
|
||||
channelType: "channel",
|
||||
channelUsers: ["U_OWNER"],
|
||||
});
|
||||
const messageHandler = getMessageHandler();
|
||||
expect(messageHandler).toBeTruthy();
|
||||
|
||||
await messageHandler!({
|
||||
event: makeDeletedEvent({ channel: "C1", user: "U_ATTACKER" }),
|
||||
body: {},
|
||||
});
|
||||
|
||||
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks thread_broadcast system events without an authenticated sender", async () => {
|
||||
enqueueSystemEventMock.mockClear();
|
||||
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
|
||||
const { getMessageHandler } = createMessagesContext({ dmPolicy: "open" });
|
||||
const messageHandler = getMessageHandler();
|
||||
expect(messageHandler).toBeTruthy();
|
||||
|
||||
await messageHandler!({
|
||||
event: {
|
||||
...makeThreadBroadcastEvent(),
|
||||
user: undefined,
|
||||
message: {
|
||||
ts: "123.456",
|
||||
it.each([
|
||||
{
|
||||
name: "enqueues message_changed system events when dmPolicy is open",
|
||||
input: { overrides: { dmPolicy: "open" }, event: makeChangedEvent() },
|
||||
calls: 1,
|
||||
},
|
||||
{
|
||||
name: "blocks message_changed system events when dmPolicy is disabled",
|
||||
input: { overrides: { dmPolicy: "disabled" }, event: makeChangedEvent() },
|
||||
calls: 0,
|
||||
},
|
||||
{
|
||||
name: "blocks message_changed system events for unauthorized senders in allowlist mode",
|
||||
input: {
|
||||
overrides: { dmPolicy: "allowlist", allowFrom: ["U2"] },
|
||||
event: makeChangedEvent({ user: "U1" }),
|
||||
},
|
||||
calls: 0,
|
||||
},
|
||||
{
|
||||
name: "blocks message_deleted system events for users outside channel users allowlist",
|
||||
input: {
|
||||
overrides: {
|
||||
dmPolicy: "open",
|
||||
channelType: "channel",
|
||||
channelUsers: ["U_OWNER"],
|
||||
},
|
||||
event: makeDeletedEvent({ channel: "C1", user: "U_ATTACKER" }),
|
||||
},
|
||||
calls: 0,
|
||||
},
|
||||
{
|
||||
name: "blocks thread_broadcast system events without an authenticated sender",
|
||||
input: {
|
||||
overrides: { dmPolicy: "open" },
|
||||
event: {
|
||||
...makeThreadBroadcastEvent(),
|
||||
user: undefined,
|
||||
message: { ts: "123.456" },
|
||||
},
|
||||
},
|
||||
body: {},
|
||||
});
|
||||
|
||||
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
|
||||
calls: 0,
|
||||
},
|
||||
])("$name", async ({ input, calls }) => {
|
||||
await runMessageCase(input);
|
||||
expect(messageQueueMock).toHaveBeenCalledTimes(calls);
|
||||
});
|
||||
|
||||
it("passes regular message events to the message handler", async () => {
|
||||
enqueueSystemEventMock.mockClear();
|
||||
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
|
||||
const { getMessageHandler, handleSlackMessage } = createMessagesContext({
|
||||
dmPolicy: "open",
|
||||
});
|
||||
const messageHandler = getMessageHandler();
|
||||
expect(messageHandler).toBeTruthy();
|
||||
messageQueueMock.mockClear();
|
||||
messageAllowMock.mockReset().mockResolvedValue([]);
|
||||
const { handler, handleSlackMessage } = createMessageHandlers({ dmPolicy: "open" });
|
||||
expect(handler).toBeTruthy();
|
||||
|
||||
await messageHandler!({
|
||||
await handler!({
|
||||
event: {
|
||||
type: "message",
|
||||
channel: "D1",
|
||||
@@ -191,6 +153,6 @@ describe("registerSlackMessageEvents", () => {
|
||||
});
|
||||
|
||||
expect(handleSlackMessage).toHaveBeenCalledTimes(1);
|
||||
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
|
||||
expect(messageQueueMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,40 +1,32 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { registerSlackPinEvents } from "./pins.js";
|
||||
import {
|
||||
createSlackSystemEventTestHarness,
|
||||
type SlackSystemEventTestOverrides,
|
||||
createSlackSystemEventTestHarness as buildPinHarness,
|
||||
type SlackSystemEventTestOverrides as PinOverrides,
|
||||
} from "./system-event-test-harness.js";
|
||||
|
||||
const enqueueSystemEventMock = vi.fn();
|
||||
const readAllowFromStoreMock = vi.fn();
|
||||
|
||||
vi.mock("../../../infra/system-events.js", () => ({
|
||||
enqueueSystemEvent: (...args: unknown[]) => enqueueSystemEventMock(...args),
|
||||
}));
|
||||
const pinEnqueueMock = vi.hoisted(() => vi.fn());
|
||||
const pinAllowMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../../../infra/system-events.js", () => {
|
||||
return { enqueueSystemEvent: pinEnqueueMock };
|
||||
});
|
||||
vi.mock("../../../pairing/pairing-store.js", () => ({
|
||||
readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args),
|
||||
readChannelAllowFromStore: pinAllowMock,
|
||||
}));
|
||||
|
||||
type SlackPinHandler = (args: { event: Record<string, unknown>; body: unknown }) => Promise<void>;
|
||||
type PinHandler = (args: { event: Record<string, unknown>; body: unknown }) => Promise<void>;
|
||||
|
||||
function createPinContext(params?: {
|
||||
overrides?: SlackSystemEventTestOverrides;
|
||||
type PinCase = {
|
||||
body?: unknown;
|
||||
event?: Record<string, unknown>;
|
||||
handler?: "added" | "removed";
|
||||
overrides?: PinOverrides;
|
||||
trackEvent?: () => void;
|
||||
shouldDropMismatchedSlackEvent?: (body: unknown) => boolean;
|
||||
}) {
|
||||
const harness = createSlackSystemEventTestHarness(params?.overrides);
|
||||
if (params?.shouldDropMismatchedSlackEvent) {
|
||||
harness.ctx.shouldDropMismatchedSlackEvent = params.shouldDropMismatchedSlackEvent;
|
||||
}
|
||||
registerSlackPinEvents({ ctx: harness.ctx, trackEvent: params?.trackEvent });
|
||||
return {
|
||||
getAddedHandler: () => harness.getHandler("pin_added") as SlackPinHandler | null,
|
||||
getRemovedHandler: () => harness.getHandler("pin_removed") as SlackPinHandler | null,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
function makePinEvent(overrides?: { user?: string; channel?: string }) {
|
||||
function makePinEvent(overrides?: { channel?: string; user?: string }) {
|
||||
return {
|
||||
type: "pin_added",
|
||||
user: overrides?.user ?? "U1",
|
||||
@@ -42,110 +34,92 @@ function makePinEvent(overrides?: { user?: string; channel?: string }) {
|
||||
event_ts: "123.456",
|
||||
item: {
|
||||
type: "message",
|
||||
message: {
|
||||
ts: "123.456",
|
||||
},
|
||||
message: { ts: "123.456" },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function installPinHandlers(args: {
|
||||
overrides?: PinOverrides;
|
||||
trackEvent?: () => void;
|
||||
shouldDropMismatchedSlackEvent?: (body: unknown) => boolean;
|
||||
}) {
|
||||
const harness = buildPinHarness(args.overrides);
|
||||
if (args.shouldDropMismatchedSlackEvent) {
|
||||
harness.ctx.shouldDropMismatchedSlackEvent = args.shouldDropMismatchedSlackEvent;
|
||||
}
|
||||
registerSlackPinEvents({ ctx: harness.ctx, trackEvent: args.trackEvent });
|
||||
return {
|
||||
added: harness.getHandler("pin_added") as PinHandler | null,
|
||||
removed: harness.getHandler("pin_removed") as PinHandler | null,
|
||||
};
|
||||
}
|
||||
|
||||
async function runPinCase(input: PinCase = {}): Promise<void> {
|
||||
pinEnqueueMock.mockClear();
|
||||
pinAllowMock.mockReset().mockResolvedValue([]);
|
||||
const { added, removed } = installPinHandlers({
|
||||
overrides: input.overrides,
|
||||
trackEvent: input.trackEvent,
|
||||
shouldDropMismatchedSlackEvent: input.shouldDropMismatchedSlackEvent,
|
||||
});
|
||||
const handlerKey = input.handler ?? "added";
|
||||
const handler = handlerKey === "removed" ? removed : added;
|
||||
expect(handler).toBeTruthy();
|
||||
const event = (input.event ?? makePinEvent()) as Record<string, unknown>;
|
||||
const body = input.body ?? {};
|
||||
await handler!({
|
||||
body,
|
||||
event,
|
||||
});
|
||||
}
|
||||
|
||||
describe("registerSlackPinEvents", () => {
|
||||
it("enqueues DM pin system events when dmPolicy is open", async () => {
|
||||
enqueueSystemEventMock.mockClear();
|
||||
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
|
||||
const { getAddedHandler } = createPinContext({ overrides: { dmPolicy: "open" } });
|
||||
const addedHandler = getAddedHandler();
|
||||
expect(addedHandler).toBeTruthy();
|
||||
|
||||
await addedHandler!({
|
||||
event: makePinEvent(),
|
||||
body: {},
|
||||
});
|
||||
|
||||
expect(enqueueSystemEventMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("blocks DM pin system events when dmPolicy is disabled", async () => {
|
||||
enqueueSystemEventMock.mockClear();
|
||||
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
|
||||
const { getAddedHandler } = createPinContext({ overrides: { dmPolicy: "disabled" } });
|
||||
const addedHandler = getAddedHandler();
|
||||
expect(addedHandler).toBeTruthy();
|
||||
|
||||
await addedHandler!({
|
||||
event: makePinEvent(),
|
||||
body: {},
|
||||
});
|
||||
|
||||
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks DM pin system events for unauthorized senders in allowlist mode", async () => {
|
||||
enqueueSystemEventMock.mockClear();
|
||||
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
|
||||
const { getAddedHandler } = createPinContext({
|
||||
overrides: { dmPolicy: "allowlist", allowFrom: ["U2"] },
|
||||
});
|
||||
const addedHandler = getAddedHandler();
|
||||
expect(addedHandler).toBeTruthy();
|
||||
|
||||
await addedHandler!({
|
||||
event: makePinEvent({ user: "U1" }),
|
||||
body: {},
|
||||
});
|
||||
|
||||
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows DM pin system events for authorized senders in allowlist mode", async () => {
|
||||
enqueueSystemEventMock.mockClear();
|
||||
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
|
||||
const { getAddedHandler } = createPinContext({
|
||||
overrides: { dmPolicy: "allowlist", allowFrom: ["U1"] },
|
||||
});
|
||||
const addedHandler = getAddedHandler();
|
||||
expect(addedHandler).toBeTruthy();
|
||||
|
||||
await addedHandler!({
|
||||
event: makePinEvent({ user: "U1" }),
|
||||
body: {},
|
||||
});
|
||||
|
||||
expect(enqueueSystemEventMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("blocks channel pin events for users outside channel users allowlist", async () => {
|
||||
enqueueSystemEventMock.mockClear();
|
||||
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
|
||||
const { getAddedHandler } = createPinContext({
|
||||
overrides: {
|
||||
dmPolicy: "open",
|
||||
channelType: "channel",
|
||||
channelUsers: ["U_OWNER"],
|
||||
it.each([
|
||||
["enqueues DM pin system events when dmPolicy is open", { overrides: { dmPolicy: "open" } }, 1],
|
||||
[
|
||||
"blocks DM pin system events when dmPolicy is disabled",
|
||||
{ overrides: { dmPolicy: "disabled" } },
|
||||
0,
|
||||
],
|
||||
[
|
||||
"blocks DM pin system events for unauthorized senders in allowlist mode",
|
||||
{
|
||||
overrides: { dmPolicy: "allowlist", allowFrom: ["U2"] },
|
||||
event: makePinEvent({ user: "U1" }),
|
||||
},
|
||||
});
|
||||
const addedHandler = getAddedHandler();
|
||||
expect(addedHandler).toBeTruthy();
|
||||
|
||||
await addedHandler!({
|
||||
event: makePinEvent({ channel: "C1", user: "U_ATTACKER" }),
|
||||
body: {},
|
||||
});
|
||||
|
||||
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
|
||||
0,
|
||||
],
|
||||
[
|
||||
"allows DM pin system events for authorized senders in allowlist mode",
|
||||
{
|
||||
overrides: { dmPolicy: "allowlist", allowFrom: ["U1"] },
|
||||
event: makePinEvent({ user: "U1" }),
|
||||
},
|
||||
1,
|
||||
],
|
||||
[
|
||||
"blocks channel pin events for users outside channel users allowlist",
|
||||
{
|
||||
overrides: {
|
||||
dmPolicy: "open",
|
||||
channelType: "channel",
|
||||
channelUsers: ["U_OWNER"],
|
||||
},
|
||||
event: makePinEvent({ channel: "C1", user: "U_ATTACKER" }),
|
||||
},
|
||||
0,
|
||||
],
|
||||
])("%s", async (_name, args: PinCase, expectedCalls: number) => {
|
||||
await runPinCase(args);
|
||||
expect(pinEnqueueMock).toHaveBeenCalledTimes(expectedCalls);
|
||||
});
|
||||
|
||||
it("does not track mismatched events", async () => {
|
||||
const trackEvent = vi.fn();
|
||||
const { getAddedHandler } = createPinContext({
|
||||
await runPinCase({
|
||||
trackEvent,
|
||||
shouldDropMismatchedSlackEvent: () => true,
|
||||
});
|
||||
const addedHandler = getAddedHandler();
|
||||
expect(addedHandler).toBeTruthy();
|
||||
|
||||
await addedHandler!({
|
||||
event: makePinEvent(),
|
||||
body: { api_app_id: "A_OTHER" },
|
||||
});
|
||||
|
||||
@@ -154,14 +128,7 @@ describe("registerSlackPinEvents", () => {
|
||||
|
||||
it("tracks accepted pin events", async () => {
|
||||
const trackEvent = vi.fn();
|
||||
const { getAddedHandler } = createPinContext({ trackEvent });
|
||||
const addedHandler = getAddedHandler();
|
||||
expect(addedHandler).toBeTruthy();
|
||||
|
||||
await addedHandler!({
|
||||
event: makePinEvent(),
|
||||
body: {},
|
||||
});
|
||||
await runPinCase({ trackEvent });
|
||||
|
||||
expect(trackEvent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -5,39 +5,33 @@ import {
|
||||
type SlackSystemEventTestOverrides,
|
||||
} from "./system-event-test-harness.js";
|
||||
|
||||
const enqueueSystemEventMock = vi.fn();
|
||||
const readAllowFromStoreMock = vi.fn();
|
||||
const reactionQueueMock = vi.fn();
|
||||
const reactionAllowMock = vi.fn();
|
||||
|
||||
vi.mock("../../../infra/system-events.js", () => ({
|
||||
enqueueSystemEvent: (...args: unknown[]) => enqueueSystemEventMock(...args),
|
||||
}));
|
||||
vi.mock("../../../infra/system-events.js", () => {
|
||||
return {
|
||||
enqueueSystemEvent: (...args: unknown[]) => reactionQueueMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../../pairing/pairing-store.js", () => ({
|
||||
readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args),
|
||||
}));
|
||||
vi.mock("../../../pairing/pairing-store.js", () => {
|
||||
return {
|
||||
readChannelAllowFromStore: (...args: unknown[]) => reactionAllowMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
type SlackReactionHandler = (args: {
|
||||
event: Record<string, unknown>;
|
||||
body: unknown;
|
||||
}) => Promise<void>;
|
||||
type ReactionHandler = (args: { event: Record<string, unknown>; body: unknown }) => Promise<void>;
|
||||
|
||||
function createReactionContext(params?: {
|
||||
type ReactionRunInput = {
|
||||
handler?: "added" | "removed";
|
||||
overrides?: SlackSystemEventTestOverrides;
|
||||
event?: Record<string, unknown>;
|
||||
body?: unknown;
|
||||
trackEvent?: () => void;
|
||||
shouldDropMismatchedSlackEvent?: (body: unknown) => boolean;
|
||||
}) {
|
||||
const harness = createSlackSystemEventTestHarness(params?.overrides);
|
||||
if (params?.shouldDropMismatchedSlackEvent) {
|
||||
harness.ctx.shouldDropMismatchedSlackEvent = params.shouldDropMismatchedSlackEvent;
|
||||
}
|
||||
registerSlackReactionEvents({ ctx: harness.ctx, trackEvent: params?.trackEvent });
|
||||
return {
|
||||
getAddedHandler: () => harness.getHandler("reaction_added") as SlackReactionHandler | null,
|
||||
getRemovedHandler: () => harness.getHandler("reaction_removed") as SlackReactionHandler | null,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
function makeReactionEvent(overrides?: { user?: string; channel?: string }) {
|
||||
function buildReactionEvent(overrides?: { user?: string; channel?: string }) {
|
||||
return {
|
||||
type: "reaction_added",
|
||||
user: overrides?.user ?? "U1",
|
||||
@@ -51,123 +45,100 @@ function makeReactionEvent(overrides?: { user?: string; channel?: string }) {
|
||||
};
|
||||
}
|
||||
|
||||
function createReactionHandlers(params: {
|
||||
overrides?: SlackSystemEventTestOverrides;
|
||||
trackEvent?: () => void;
|
||||
shouldDropMismatchedSlackEvent?: (body: unknown) => boolean;
|
||||
}) {
|
||||
const harness = createSlackSystemEventTestHarness(params.overrides);
|
||||
if (params.shouldDropMismatchedSlackEvent) {
|
||||
harness.ctx.shouldDropMismatchedSlackEvent = params.shouldDropMismatchedSlackEvent;
|
||||
}
|
||||
registerSlackReactionEvents({ ctx: harness.ctx, trackEvent: params.trackEvent });
|
||||
return {
|
||||
added: harness.getHandler("reaction_added") as ReactionHandler | null,
|
||||
removed: harness.getHandler("reaction_removed") as ReactionHandler | null,
|
||||
};
|
||||
}
|
||||
|
||||
async function executeReactionCase(input: ReactionRunInput = {}) {
|
||||
reactionQueueMock.mockClear();
|
||||
reactionAllowMock.mockReset().mockResolvedValue([]);
|
||||
const handlers = createReactionHandlers({
|
||||
overrides: input.overrides,
|
||||
trackEvent: input.trackEvent,
|
||||
shouldDropMismatchedSlackEvent: input.shouldDropMismatchedSlackEvent,
|
||||
});
|
||||
const handler = handlers[input.handler ?? "added"];
|
||||
expect(handler).toBeTruthy();
|
||||
await handler!({
|
||||
event: (input.event ?? buildReactionEvent()) as Record<string, unknown>,
|
||||
body: input.body ?? {},
|
||||
});
|
||||
}
|
||||
|
||||
describe("registerSlackReactionEvents", () => {
|
||||
it("enqueues DM reaction system events when dmPolicy is open", async () => {
|
||||
enqueueSystemEventMock.mockClear();
|
||||
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
|
||||
const { getAddedHandler } = createReactionContext({ overrides: { dmPolicy: "open" } });
|
||||
const addedHandler = getAddedHandler();
|
||||
expect(addedHandler).toBeTruthy();
|
||||
|
||||
await addedHandler!({
|
||||
event: makeReactionEvent(),
|
||||
body: {},
|
||||
});
|
||||
|
||||
expect(enqueueSystemEventMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("blocks DM reaction system events when dmPolicy is disabled", async () => {
|
||||
enqueueSystemEventMock.mockClear();
|
||||
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
|
||||
const { getAddedHandler } = createReactionContext({ overrides: { dmPolicy: "disabled" } });
|
||||
const addedHandler = getAddedHandler();
|
||||
expect(addedHandler).toBeTruthy();
|
||||
|
||||
await addedHandler!({
|
||||
event: makeReactionEvent(),
|
||||
body: {},
|
||||
});
|
||||
|
||||
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks DM reaction system events for unauthorized senders in allowlist mode", async () => {
|
||||
enqueueSystemEventMock.mockClear();
|
||||
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
|
||||
const { getAddedHandler } = createReactionContext({
|
||||
overrides: { dmPolicy: "allowlist", allowFrom: ["U2"] },
|
||||
});
|
||||
const addedHandler = getAddedHandler();
|
||||
expect(addedHandler).toBeTruthy();
|
||||
|
||||
await addedHandler!({
|
||||
event: makeReactionEvent({ user: "U1" }),
|
||||
body: {},
|
||||
});
|
||||
|
||||
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows DM reaction system events for authorized senders in allowlist mode", async () => {
|
||||
enqueueSystemEventMock.mockClear();
|
||||
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
|
||||
const { getAddedHandler } = createReactionContext({
|
||||
overrides: { dmPolicy: "allowlist", allowFrom: ["U1"] },
|
||||
});
|
||||
const addedHandler = getAddedHandler();
|
||||
expect(addedHandler).toBeTruthy();
|
||||
|
||||
await addedHandler!({
|
||||
event: makeReactionEvent({ user: "U1" }),
|
||||
body: {},
|
||||
});
|
||||
|
||||
expect(enqueueSystemEventMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("enqueues channel reaction events regardless of dmPolicy", async () => {
|
||||
enqueueSystemEventMock.mockClear();
|
||||
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
|
||||
const { getRemovedHandler } = createReactionContext({
|
||||
overrides: { dmPolicy: "disabled", channelType: "channel" },
|
||||
});
|
||||
const removedHandler = getRemovedHandler();
|
||||
expect(removedHandler).toBeTruthy();
|
||||
|
||||
await removedHandler!({
|
||||
event: {
|
||||
...makeReactionEvent({ channel: "C1" }),
|
||||
type: "reaction_removed",
|
||||
it.each([
|
||||
{
|
||||
name: "enqueues DM reaction system events when dmPolicy is open",
|
||||
args: { overrides: { dmPolicy: "open" } },
|
||||
expectedCalls: 1,
|
||||
},
|
||||
{
|
||||
name: "blocks DM reaction system events when dmPolicy is disabled",
|
||||
args: { overrides: { dmPolicy: "disabled" } },
|
||||
expectedCalls: 0,
|
||||
},
|
||||
{
|
||||
name: "blocks DM reaction system events for unauthorized senders in allowlist mode",
|
||||
args: {
|
||||
overrides: { dmPolicy: "allowlist", allowFrom: ["U2"] },
|
||||
event: buildReactionEvent({ user: "U1" }),
|
||||
},
|
||||
body: {},
|
||||
});
|
||||
|
||||
expect(enqueueSystemEventMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("blocks channel reaction events for users outside channel users allowlist", async () => {
|
||||
enqueueSystemEventMock.mockClear();
|
||||
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
|
||||
const { getAddedHandler } = createReactionContext({
|
||||
overrides: {
|
||||
dmPolicy: "open",
|
||||
channelType: "channel",
|
||||
channelUsers: ["U_OWNER"],
|
||||
expectedCalls: 0,
|
||||
},
|
||||
{
|
||||
name: "allows DM reaction system events for authorized senders in allowlist mode",
|
||||
args: {
|
||||
overrides: { dmPolicy: "allowlist", allowFrom: ["U1"] },
|
||||
event: buildReactionEvent({ user: "U1" }),
|
||||
},
|
||||
});
|
||||
const addedHandler = getAddedHandler();
|
||||
expect(addedHandler).toBeTruthy();
|
||||
|
||||
await addedHandler!({
|
||||
event: makeReactionEvent({ channel: "C1", user: "U_ATTACKER" }),
|
||||
body: {},
|
||||
});
|
||||
|
||||
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
|
||||
expectedCalls: 1,
|
||||
},
|
||||
{
|
||||
name: "enqueues channel reaction events regardless of dmPolicy",
|
||||
args: {
|
||||
handler: "removed" as const,
|
||||
overrides: { dmPolicy: "disabled", channelType: "channel" },
|
||||
event: {
|
||||
...buildReactionEvent({ channel: "C1" }),
|
||||
type: "reaction_removed",
|
||||
},
|
||||
},
|
||||
expectedCalls: 1,
|
||||
},
|
||||
{
|
||||
name: "blocks channel reaction events for users outside channel users allowlist",
|
||||
args: {
|
||||
overrides: {
|
||||
dmPolicy: "open",
|
||||
channelType: "channel",
|
||||
channelUsers: ["U_OWNER"],
|
||||
},
|
||||
event: buildReactionEvent({ channel: "C1", user: "U_ATTACKER" }),
|
||||
},
|
||||
expectedCalls: 0,
|
||||
},
|
||||
])("$name", async ({ args, expectedCalls }) => {
|
||||
await executeReactionCase(args);
|
||||
expect(reactionQueueMock).toHaveBeenCalledTimes(expectedCalls);
|
||||
});
|
||||
|
||||
it("does not track mismatched events", async () => {
|
||||
const trackEvent = vi.fn();
|
||||
const { getAddedHandler } = createReactionContext({
|
||||
await executeReactionCase({
|
||||
trackEvent,
|
||||
shouldDropMismatchedSlackEvent: () => true,
|
||||
});
|
||||
const addedHandler = getAddedHandler();
|
||||
expect(addedHandler).toBeTruthy();
|
||||
|
||||
await addedHandler!({
|
||||
event: makeReactionEvent(),
|
||||
body: { api_app_id: "A_OTHER" },
|
||||
});
|
||||
|
||||
@@ -176,14 +147,7 @@ describe("registerSlackReactionEvents", () => {
|
||||
|
||||
it("tracks accepted message reactions", async () => {
|
||||
const trackEvent = vi.fn();
|
||||
const { getAddedHandler } = createReactionContext({ trackEvent });
|
||||
const addedHandler = getAddedHandler();
|
||||
expect(addedHandler).toBeTruthy();
|
||||
|
||||
await addedHandler!({
|
||||
event: makeReactionEvent(),
|
||||
body: {},
|
||||
});
|
||||
await executeReactionCase({ trackEvent });
|
||||
|
||||
expect(trackEvent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -189,6 +189,73 @@ describe("slack prepareSlackMessage inbound contract", () => {
|
||||
return prepareMessageWith(ctx, createThreadAccount(), createThreadReplyMessage(overrides));
|
||||
}
|
||||
|
||||
function createDmScopeMainSlackCtx(): SlackMonitorContext {
|
||||
const slackCtx = createInboundSlackCtx({
|
||||
cfg: {
|
||||
channels: { slack: { enabled: true } },
|
||||
session: { dmScope: "main" },
|
||||
} as OpenClawConfig,
|
||||
});
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
slackCtx.resolveUserName = async () => ({ name: "Alice" }) as any;
|
||||
// Simulate API returning correct type for DM channel
|
||||
slackCtx.resolveChannelName = async () => ({ name: undefined, type: "im" as const });
|
||||
return slackCtx;
|
||||
}
|
||||
|
||||
function createMainScopedDmMessage(overrides: Partial<SlackMessageEvent>): SlackMessageEvent {
|
||||
return createSlackMessage({
|
||||
channel: "D0ACP6B1T8V",
|
||||
user: "U1",
|
||||
text: "hello from DM",
|
||||
ts: "1.000",
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function expectMainScopedDmClassification(
|
||||
prepared: Awaited<ReturnType<typeof prepareSlackMessage>>,
|
||||
options?: { includeFromCheck?: boolean },
|
||||
) {
|
||||
expect(prepared).toBeTruthy();
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
expectInboundContextContract(prepared!.ctxPayload as any);
|
||||
expect(prepared!.isDirectMessage).toBe(true);
|
||||
expect(prepared!.route.sessionKey).toBe("agent:main:main");
|
||||
expect(prepared!.ctxPayload.ChatType).toBe("direct");
|
||||
if (options?.includeFromCheck) {
|
||||
expect(prepared!.ctxPayload.From).toContain("slack:U1");
|
||||
}
|
||||
}
|
||||
|
||||
function createReplyToAllSlackCtx(params?: {
|
||||
groupPolicy?: "open";
|
||||
defaultRequireMention?: boolean;
|
||||
asChannel?: boolean;
|
||||
}): SlackMonitorContext {
|
||||
const slackCtx = createInboundSlackCtx({
|
||||
cfg: {
|
||||
channels: {
|
||||
slack: {
|
||||
enabled: true,
|
||||
replyToMode: "all",
|
||||
...(params?.groupPolicy ? { groupPolicy: params.groupPolicy } : {}),
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
replyToMode: "all",
|
||||
...(params?.defaultRequireMention === undefined
|
||||
? {}
|
||||
: { defaultRequireMention: params.defaultRequireMention }),
|
||||
});
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
slackCtx.resolveUserName = async () => ({ name: "Alice" }) as any;
|
||||
if (params?.asChannel) {
|
||||
slackCtx.resolveChannelName = async () => ({ name: "general", type: "channel" });
|
||||
}
|
||||
return slackCtx;
|
||||
}
|
||||
|
||||
it("produces a finalized MsgContext", async () => {
|
||||
const message: SlackMessageEvent = {
|
||||
channel: "D123",
|
||||
@@ -331,179 +398,34 @@ describe("slack prepareSlackMessage inbound contract", () => {
|
||||
});
|
||||
|
||||
it("classifies D-prefix DMs correctly even when channel_type is wrong", async () => {
|
||||
const slackCtx = createSlackMonitorContext({
|
||||
cfg: {
|
||||
channels: { slack: { enabled: true } },
|
||||
session: { dmScope: "main" },
|
||||
} 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: [],
|
||||
allowNameMatching: false,
|
||||
groupDmEnabled: true,
|
||||
groupDmChannels: [],
|
||||
defaultRequireMention: true,
|
||||
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;
|
||||
// Simulate API returning correct type for DM channel
|
||||
slackCtx.resolveChannelName = async () => ({ name: undefined, type: "im" as const });
|
||||
const prepared = await prepareMessageWith(
|
||||
createDmScopeMainSlackCtx(),
|
||||
createSlackAccount(),
|
||||
createMainScopedDmMessage({
|
||||
// Bug scenario: D-prefix channel but Slack event says channel_type: "channel"
|
||||
channel_type: "channel",
|
||||
}),
|
||||
);
|
||||
|
||||
const account: ResolvedSlackAccount = {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
botTokenSource: "config",
|
||||
appTokenSource: "config",
|
||||
userTokenSource: "none",
|
||||
config: {},
|
||||
};
|
||||
|
||||
// Bug scenario: D-prefix channel but Slack event says channel_type: "channel"
|
||||
const message: SlackMessageEvent = {
|
||||
channel: "D0ACP6B1T8V",
|
||||
channel_type: "channel",
|
||||
user: "U1",
|
||||
text: "hello from DM",
|
||||
ts: "1.000",
|
||||
} as SlackMessageEvent;
|
||||
|
||||
const prepared = await prepareSlackMessage({
|
||||
ctx: slackCtx,
|
||||
account,
|
||||
message,
|
||||
opts: { source: "message" },
|
||||
});
|
||||
|
||||
expect(prepared).toBeTruthy();
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
expectInboundContextContract(prepared!.ctxPayload as any);
|
||||
// Should be classified as DM, not channel
|
||||
expect(prepared!.isDirectMessage).toBe(true);
|
||||
// DM with dmScope: "main" should route to the main session
|
||||
expect(prepared!.route.sessionKey).toBe("agent:main:main");
|
||||
// ChatType should be "direct", not "channel"
|
||||
expect(prepared!.ctxPayload.ChatType).toBe("direct");
|
||||
// From should use user ID (DM pattern), not channel ID
|
||||
expect(prepared!.ctxPayload.From).toContain("slack:U1");
|
||||
expectMainScopedDmClassification(prepared, { includeFromCheck: true });
|
||||
});
|
||||
|
||||
it("classifies D-prefix DMs when channel_type is missing", async () => {
|
||||
const slackCtx = createSlackMonitorContext({
|
||||
cfg: {
|
||||
channels: { slack: { enabled: true } },
|
||||
session: { dmScope: "main" },
|
||||
} 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: [],
|
||||
allowNameMatching: false,
|
||||
groupDmEnabled: true,
|
||||
groupDmChannels: [],
|
||||
defaultRequireMention: true,
|
||||
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;
|
||||
// Simulate API returning correct type for DM channel
|
||||
slackCtx.resolveChannelName = async () => ({ name: undefined, type: "im" as const });
|
||||
|
||||
const account: ResolvedSlackAccount = {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
botTokenSource: "config",
|
||||
appTokenSource: "config",
|
||||
userTokenSource: "none",
|
||||
config: {},
|
||||
};
|
||||
|
||||
// channel_type missing — should infer from D-prefix
|
||||
const message: SlackMessageEvent = {
|
||||
channel: "D0ACP6B1T8V",
|
||||
user: "U1",
|
||||
text: "hello from DM",
|
||||
ts: "1.000",
|
||||
} as SlackMessageEvent;
|
||||
|
||||
const prepared = await prepareSlackMessage({
|
||||
ctx: slackCtx,
|
||||
account,
|
||||
const message = createMainScopedDmMessage({});
|
||||
delete message.channel_type;
|
||||
const prepared = await prepareMessageWith(
|
||||
createDmScopeMainSlackCtx(),
|
||||
createSlackAccount(),
|
||||
// channel_type missing — should infer from D-prefix.
|
||||
message,
|
||||
opts: { source: "message" },
|
||||
});
|
||||
);
|
||||
|
||||
expect(prepared).toBeTruthy();
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
expectInboundContextContract(prepared!.ctxPayload as any);
|
||||
expect(prepared!.isDirectMessage).toBe(true);
|
||||
expect(prepared!.route.sessionKey).toBe("agent:main:main");
|
||||
expect(prepared!.ctxPayload.ChatType).toBe("direct");
|
||||
expectMainScopedDmClassification(prepared);
|
||||
});
|
||||
|
||||
it("sets MessageThreadId for top-level messages when replyToMode=all", async () => {
|
||||
const slackCtx = createInboundSlackCtx({
|
||||
cfg: {
|
||||
channels: { slack: { enabled: true, replyToMode: "all" } },
|
||||
} as OpenClawConfig,
|
||||
replyToMode: "all",
|
||||
});
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
slackCtx.resolveUserName = async () => ({ name: "Alice" }) as any;
|
||||
|
||||
const prepared = await prepareMessageWith(
|
||||
slackCtx,
|
||||
createReplyToAllSlackCtx(),
|
||||
createSlackAccount({ replyToMode: "all" }),
|
||||
createSlackMessage({}),
|
||||
);
|
||||
@@ -513,17 +435,8 @@ describe("slack prepareSlackMessage inbound contract", () => {
|
||||
});
|
||||
|
||||
it("respects replyToModeByChatType.direct override for DMs", async () => {
|
||||
const slackCtx = createInboundSlackCtx({
|
||||
cfg: {
|
||||
channels: { slack: { enabled: true, replyToMode: "all" } },
|
||||
} as OpenClawConfig,
|
||||
replyToMode: "all",
|
||||
});
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
slackCtx.resolveUserName = async () => ({ name: "Alice" }) as any;
|
||||
|
||||
const prepared = await prepareMessageWith(
|
||||
slackCtx,
|
||||
createReplyToAllSlackCtx(),
|
||||
createSlackAccount({ replyToMode: "all", replyToModeByChatType: { direct: "off" } }),
|
||||
createSlackMessage({}), // DM (channel_type: "im")
|
||||
);
|
||||
@@ -534,19 +447,12 @@ describe("slack prepareSlackMessage inbound contract", () => {
|
||||
});
|
||||
|
||||
it("still threads channel messages when replyToModeByChatType.direct is off", async () => {
|
||||
const slackCtx = createInboundSlackCtx({
|
||||
cfg: {
|
||||
channels: { slack: { enabled: true, replyToMode: "all", groupPolicy: "open" } },
|
||||
} as OpenClawConfig,
|
||||
replyToMode: "all",
|
||||
defaultRequireMention: false,
|
||||
});
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
slackCtx.resolveUserName = async () => ({ name: "Alice" }) as any;
|
||||
slackCtx.resolveChannelName = async () => ({ name: "general", type: "channel" });
|
||||
|
||||
const prepared = await prepareMessageWith(
|
||||
slackCtx,
|
||||
createReplyToAllSlackCtx({
|
||||
groupPolicy: "open",
|
||||
defaultRequireMention: false,
|
||||
asChannel: true,
|
||||
}),
|
||||
createSlackAccount({ replyToMode: "all", replyToModeByChatType: { direct: "off" } }),
|
||||
createSlackMessage({ channel: "C123", channel_type: "channel" }),
|
||||
);
|
||||
@@ -557,17 +463,8 @@ describe("slack prepareSlackMessage inbound contract", () => {
|
||||
});
|
||||
|
||||
it("respects dm.replyToMode legacy override for DMs", async () => {
|
||||
const slackCtx = createInboundSlackCtx({
|
||||
cfg: {
|
||||
channels: { slack: { enabled: true, replyToMode: "all" } },
|
||||
} as OpenClawConfig,
|
||||
replyToMode: "all",
|
||||
});
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
slackCtx.resolveUserName = async () => ({ name: "Alice" }) as any;
|
||||
|
||||
const prepared = await prepareMessageWith(
|
||||
slackCtx,
|
||||
createReplyToAllSlackCtx(),
|
||||
createSlackAccount({ replyToMode: "all", dm: { replyToMode: "off" } }),
|
||||
createSlackMessage({}), // DM
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user