mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 20:05:46 -06:00
a968d15f63
* refactor(slack): detect Enterprise Grid installs at runtime * fix(slack): preserve enterprise bot identity * style(slack): format enterprise identity test * fix(slack): scope enterprise config by workspace * fix(slack): preserve qualified binding casing * feat(slack): enable enterprise presence polling * fix(slack): normalize enterprise binding accounts * fix(slack): require workspace-scoped Grid targets * docs(slack): document Enterprise policy target forms * fix(slack): preserve Grid pairing workspace scope * refactor(slack): simplify scoped target formatting * fix(slack): repair Enterprise Grid CI checks * chore: refresh generated Slack metadata * refactor(slack): simplify optional workspace scope * test(slack): infer installation state handle types * chore: resolve plugin SDK baseline conflict * fix(ci): regenerate plugin SDK API baseline on Linux * fix(slack): allow standalone workspace sends --------- Co-authored-by: Sarah Fortune <sarah.fortune@gmail.com>
352 lines
13 KiB
TypeScript
352 lines
13 KiB
TypeScript
// Slack tests cover listener-scoped Enterprise Grid delivery through the canonical sender.
|
|
import type { WebClient } from "@slack/web-api";
|
|
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { registerSlackInstallationState } from "./installation-identity-state.js";
|
|
import {
|
|
clearSlackThreadParticipationCache,
|
|
hasSlackThreadParticipation,
|
|
} from "./sent-thread-cache.js";
|
|
|
|
const loadOutboundMediaFromUrl = vi.hoisted(() =>
|
|
vi.fn(async () => ({
|
|
buffer: Buffer.from("image"),
|
|
contentType: "image/png",
|
|
fileName: "image.png",
|
|
})),
|
|
);
|
|
const fetchWithSsrFGuard = vi.hoisted(() => vi.fn());
|
|
const getSlackWriteClientMock = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock("openclaw/plugin-sdk/fetch-runtime", () => ({
|
|
withTrustedEnvProxyGuardedFetchMode: (value: unknown) => value,
|
|
}));
|
|
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({ fetchWithSsrFGuard }));
|
|
vi.mock("./runtime-api.js", async () => {
|
|
const actual = await vi.importActual<typeof import("./runtime-api.js")>("./runtime-api.js");
|
|
return { ...actual, loadOutboundMediaFromUrl };
|
|
});
|
|
vi.mock("./client.js", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("./client.js")>();
|
|
return { ...actual, getSlackWriteClient: getSlackWriteClientMock };
|
|
});
|
|
|
|
const { sendMessageSlack } = await import("./send.js");
|
|
|
|
type EnterpriseTestClient = WebClient & {
|
|
chat: { postMessage: ReturnType<typeof vi.fn> };
|
|
conversations: { open: ReturnType<typeof vi.fn> };
|
|
files: {
|
|
getUploadURLExternal: ReturnType<typeof vi.fn>;
|
|
completeUploadExternal: ReturnType<typeof vi.fn>;
|
|
};
|
|
};
|
|
|
|
const ENTERPRISE_CFG: OpenClawConfig = {
|
|
channels: {
|
|
slack: {},
|
|
},
|
|
};
|
|
|
|
function createEnterpriseClient(): EnterpriseTestClient {
|
|
return {
|
|
chat: {
|
|
postMessage: vi.fn(async () => ({ ok: true, ts: "123.456", channel: "C123" })),
|
|
},
|
|
conversations: {
|
|
open: vi.fn(async () => ({ channel: { id: "D123" } })),
|
|
},
|
|
files: {
|
|
getUploadURLExternal: vi.fn(async () => ({
|
|
ok: true,
|
|
upload_url: "https://files.slack.com/upload",
|
|
file_id: "F123",
|
|
})),
|
|
completeUploadExternal: vi.fn(async () => ({ ok: true })),
|
|
},
|
|
} as unknown as EnterpriseTestClient;
|
|
}
|
|
|
|
function eventScope(client: WebClient, teamId = "T1", uploadCompletionClient: WebClient = client) {
|
|
return {
|
|
teamId,
|
|
client,
|
|
uploadCompletionClient,
|
|
};
|
|
}
|
|
|
|
function enterpriseOptions(
|
|
client: WebClient,
|
|
teamId = "T1",
|
|
uploadCompletionClient: WebClient = client,
|
|
) {
|
|
return {
|
|
cfg: ENTERPRISE_CFG,
|
|
eventScope: eventScope(client, teamId, uploadCompletionClient),
|
|
};
|
|
}
|
|
|
|
function postPayload(client: EnterpriseTestClient, index = 0): Record<string, unknown> {
|
|
const payload = client.chat.postMessage.mock.calls[index]?.[0];
|
|
if (!payload || typeof payload !== "object") {
|
|
throw new Error(`chat.postMessage call ${index} missing`);
|
|
}
|
|
return payload as Record<string, unknown>;
|
|
}
|
|
|
|
function deferredPostMessage(ts: string) {
|
|
let release!: () => void;
|
|
const promise = new Promise<{ ok: true; ts: string; channel: string }>((resolve) => {
|
|
release = () => resolve({ ok: true, ts, channel: "C123" });
|
|
});
|
|
return { promise, release };
|
|
}
|
|
|
|
describe("sendMessageSlack Enterprise listener scope", () => {
|
|
beforeEach(() => {
|
|
clearSlackThreadParticipationCache();
|
|
loadOutboundMediaFromUrl.mockClear();
|
|
fetchWithSsrFGuard.mockReset();
|
|
getSlackWriteClientMock.mockReset();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("creates a workspace-scoped client for a qualified detached send", async () => {
|
|
const scopedClient = createEnterpriseClient();
|
|
const injectedClient = createEnterpriseClient();
|
|
getSlackWriteClientMock.mockReturnValue(scopedClient);
|
|
const installationState = registerSlackInstallationState("default", "enterprise");
|
|
try {
|
|
await sendMessageSlack("team:T123:channel:C08GQH53EJM", "hello", {
|
|
cfg: ENTERPRISE_CFG,
|
|
token: "xoxb-enterprise",
|
|
client: injectedClient,
|
|
});
|
|
|
|
expect(getSlackWriteClientMock).toHaveBeenCalledWith("xoxb-enterprise", {
|
|
teamId: "T123",
|
|
});
|
|
expect(scopedClient.chat.postMessage).toHaveBeenCalledWith(
|
|
expect.objectContaining({ channel: "C08GQH53EJM", text: "hello" }),
|
|
);
|
|
expect(injectedClient.chat.postMessage).not.toHaveBeenCalled();
|
|
} finally {
|
|
installationState.release();
|
|
}
|
|
});
|
|
|
|
it("rejects a bare detached target for an authenticated Enterprise install", async () => {
|
|
const installationState = registerSlackInstallationState("default", "enterprise");
|
|
try {
|
|
await expect(
|
|
sendMessageSlack("C08GQH53EJM", "hello", {
|
|
cfg: ENTERPRISE_CFG,
|
|
token: "xoxb-enterprise",
|
|
}),
|
|
).rejects.toThrow("unsupported_enterprise_slack_delivery");
|
|
expect(getSlackWriteClientMock).not.toHaveBeenCalled();
|
|
} finally {
|
|
installationState.release();
|
|
}
|
|
});
|
|
|
|
it("uses the exact listener client without a token or team_id method payload", async () => {
|
|
const client = createEnterpriseClient();
|
|
const installationState = registerSlackInstallationState("default", "enterprise");
|
|
try {
|
|
const result = await sendMessageSlack("channel:c08gqh53ejm", "hello", {
|
|
...enterpriseOptions(client),
|
|
cfg: {
|
|
channels: {
|
|
slack: {
|
|
botToken: "xoxb-enterprise",
|
|
unfurlLinks: true,
|
|
unfurlMedia: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(client.chat.postMessage).toHaveBeenCalledOnce();
|
|
expect(postPayload(client)).toEqual({
|
|
channel: "C08GQH53EJM",
|
|
text: "hello",
|
|
unfurl_links: false,
|
|
unfurl_media: true,
|
|
});
|
|
expect(postPayload(client)).not.toHaveProperty("team_id");
|
|
expect(client.conversations.open).not.toHaveBeenCalled();
|
|
expect(result).toMatchObject({ messageId: "123.456", channelId: "C123" });
|
|
} finally {
|
|
installationState.release();
|
|
}
|
|
});
|
|
|
|
it.each(["U123", "user:U123", "#general", "slack:C123", "team:T123:channel:C08GQH53EJM"])(
|
|
"rejects unsupported listener-owned target %s",
|
|
async (target) => {
|
|
const client = createEnterpriseClient();
|
|
|
|
await expect(sendMessageSlack(target, "hello", enterpriseOptions(client))).rejects.toThrow(
|
|
"unsupported_enterprise_slack_delivery_target",
|
|
);
|
|
expect(client.chat.postMessage).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it("workspace-qualifies the send queue", async () => {
|
|
const firstClient = createEnterpriseClient();
|
|
const secondClient = createEnterpriseClient();
|
|
const firstDeferred = deferredPostMessage("1.000");
|
|
const secondDeferred = deferredPostMessage("2.000");
|
|
firstClient.chat.postMessage.mockReturnValueOnce(firstDeferred.promise);
|
|
secondClient.chat.postMessage.mockReturnValueOnce(secondDeferred.promise);
|
|
|
|
const first = sendMessageSlack("C123", "first", enterpriseOptions(firstClient, "T1"));
|
|
await vi.waitFor(() => expect(firstClient.chat.postMessage).toHaveBeenCalledOnce());
|
|
const second = sendMessageSlack("C123", "second", enterpriseOptions(secondClient, "T2"));
|
|
await vi.waitFor(() => expect(secondClient.chat.postMessage).toHaveBeenCalledOnce());
|
|
|
|
firstDeferred.release();
|
|
secondDeferred.release();
|
|
await Promise.all([first, second]);
|
|
});
|
|
|
|
it("serializes one workspace and snapshots its validated client before enqueue", async () => {
|
|
const firstClient = createEnterpriseClient();
|
|
const secondClient = createEnterpriseClient();
|
|
const replacementClient = createEnterpriseClient();
|
|
const firstDeferred = deferredPostMessage("1.000");
|
|
firstClient.chat.postMessage.mockReturnValueOnce(firstDeferred.promise);
|
|
const secondScope = eventScope(secondClient, "T1");
|
|
const secondOptions = {
|
|
cfg: ENTERPRISE_CFG,
|
|
eventScope: secondScope,
|
|
};
|
|
|
|
const first = sendMessageSlack("C123", "first", enterpriseOptions(firstClient, "T1"));
|
|
await vi.waitFor(() => expect(firstClient.chat.postMessage).toHaveBeenCalledOnce());
|
|
const second = sendMessageSlack("C123", "second", secondOptions);
|
|
await Promise.resolve();
|
|
expect(secondClient.chat.postMessage).not.toHaveBeenCalled();
|
|
|
|
secondScope.client = replacementClient;
|
|
firstDeferred.release();
|
|
await Promise.all([first, second]);
|
|
|
|
expect(secondClient.chat.postMessage).toHaveBeenCalledOnce();
|
|
expect(replacementClient.chat.postMessage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("workspace-qualifies thread participation", async () => {
|
|
const client = createEnterpriseClient();
|
|
|
|
await sendMessageSlack("C123", "thread reply", {
|
|
...enterpriseOptions(client, "T1"),
|
|
threadTs: "1712345678.123456",
|
|
});
|
|
|
|
expect(hasSlackThreadParticipation("default", "C123", "1712345678.123456", "T1")).toBe(true);
|
|
expect(hasSlackThreadParticipation("default", "C123", "1712345678.123456")).toBe(false);
|
|
expect(hasSlackThreadParticipation("default", "C123", "1712345678.123456", "T2")).toBe(false);
|
|
});
|
|
|
|
it("uses listener-resolved chunk limits and returns one aggregate receipt", async () => {
|
|
const client = createEnterpriseClient();
|
|
client.chat.postMessage
|
|
.mockResolvedValueOnce({ ok: true, ts: "123.001", channel: "C123" })
|
|
.mockResolvedValueOnce({ ok: true, ts: "123.002", channel: "C123" })
|
|
.mockResolvedValueOnce({ ok: true, ts: "123.003", channel: "C123" });
|
|
|
|
const result = await sendMessageSlack("C123", "12345678Z", {
|
|
...enterpriseOptions(client),
|
|
textLimit: 4,
|
|
});
|
|
|
|
expect(client.chat.postMessage.mock.calls.map((call) => call[0]?.text)).toEqual([
|
|
"1234",
|
|
"5678",
|
|
"Z",
|
|
]);
|
|
expect(result.receipt).toMatchObject({
|
|
primaryPlatformMessageId: "123.001",
|
|
platformMessageIds: ["123.001", "123.002", "123.003"],
|
|
});
|
|
});
|
|
|
|
it("fails closed when the listener client returns no message timestamp", async () => {
|
|
const client = createEnterpriseClient();
|
|
client.chat.postMessage.mockResolvedValueOnce({ ok: true, channel: "C123" });
|
|
|
|
await expect(sendMessageSlack("C123", "hello", enterpriseOptions(client))).rejects.toThrow(
|
|
"Slack chat.postMessage returned no message timestamp",
|
|
);
|
|
});
|
|
|
|
it("rejects Enterprise media before upload without the one-shot completion client", async () => {
|
|
const client = createEnterpriseClient();
|
|
const scope = eventScope(client);
|
|
delete (scope as { uploadCompletionClient?: WebClient }).uploadCompletionClient;
|
|
|
|
await expect(
|
|
sendMessageSlack("C123", "caption", {
|
|
cfg: ENTERPRISE_CFG,
|
|
eventScope: scope,
|
|
mediaUrl: "https://example.com/image.png",
|
|
}),
|
|
).rejects.toThrow("missing_enterprise_slack_upload_completion_client");
|
|
|
|
expect(client.files.getUploadURLExternal).not.toHaveBeenCalled();
|
|
expect(fetchWithSsrFGuard).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("uses a completion-only client and keeps remaining posts on the listener client", async () => {
|
|
const release = vi.fn(async () => {});
|
|
fetchWithSsrFGuard.mockResolvedValue({
|
|
response: { ok: true, status: 200 },
|
|
release,
|
|
});
|
|
const listenerClient = createEnterpriseClient();
|
|
const uploadCompletionClient = createEnterpriseClient();
|
|
listenerClient.chat.postMessage
|
|
.mockResolvedValueOnce({ ok: true, ts: "123.001", channel: "C123" })
|
|
.mockResolvedValueOnce({ ok: true, ts: "123.002", channel: "C123" });
|
|
|
|
const result = await sendMessageSlack("C123", "12345678abcdefghZ", {
|
|
...enterpriseOptions(listenerClient, "T1", uploadCompletionClient),
|
|
mediaUrl: "https://example.com/image.png",
|
|
textLimit: 8,
|
|
mediaMaxBytes: 5,
|
|
});
|
|
|
|
expect(loadOutboundMediaFromUrl).toHaveBeenCalledWith(
|
|
"https://example.com/image.png",
|
|
expect.objectContaining({ maxBytes: 5 }),
|
|
);
|
|
expect(fetchWithSsrFGuard).toHaveBeenCalledWith(
|
|
expect.objectContaining({ auditContext: "slack-enterprise-immediate-upload" }),
|
|
);
|
|
expect(listenerClient.files.getUploadURLExternal).toHaveBeenCalledOnce();
|
|
expect(listenerClient.files.completeUploadExternal).not.toHaveBeenCalled();
|
|
expect(uploadCompletionClient.files.getUploadURLExternal).not.toHaveBeenCalled();
|
|
expect(uploadCompletionClient.files.completeUploadExternal).toHaveBeenCalledWith({
|
|
files: [{ id: "F123", title: "image.png" }],
|
|
channel_id: "C123",
|
|
initial_comment: "12345678",
|
|
});
|
|
expect(listenerClient.chat.postMessage.mock.calls.map((call) => call[0]?.text)).toEqual([
|
|
"abcdefgh",
|
|
"Z",
|
|
]);
|
|
expect(uploadCompletionClient.chat.postMessage).not.toHaveBeenCalled();
|
|
expect(release).toHaveBeenCalledOnce();
|
|
expect(result.receipt).toMatchObject({
|
|
primaryPlatformMessageId: "F123",
|
|
platformMessageIds: ["F123", "123.001", "123.002"],
|
|
});
|
|
});
|
|
});
|