mirror of https://github.com/openclaw/openclaw.git
233 lines
7.1 KiB
TypeScript
233 lines
7.1 KiB
TypeScript
import type { App } from "@slack/bolt";
|
|
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
|
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const sendMessageSlackMock = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock("../send.js", () => ({
|
|
sendMessageSlack: sendMessageSlackMock,
|
|
}));
|
|
|
|
let SlackExecApprovalHandler: typeof import("./exec-approvals.js").SlackExecApprovalHandler;
|
|
|
|
function buildConfig(
|
|
target: "dm" | "channel" | "both" = "dm",
|
|
slackOverrides?: Partial<NonNullable<NonNullable<OpenClawConfig["channels"]>["slack"]>>,
|
|
): OpenClawConfig {
|
|
const configuredExecApprovals = slackOverrides?.execApprovals;
|
|
return {
|
|
channels: {
|
|
slack: {
|
|
botToken: "xoxb-test",
|
|
appToken: "xapp-test",
|
|
...slackOverrides,
|
|
execApprovals: configuredExecApprovals ?? {
|
|
enabled: true,
|
|
approvers: ["U123APPROVER"],
|
|
target,
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
}
|
|
|
|
function buildApp(): App {
|
|
return {
|
|
client: {
|
|
chat: {
|
|
update: vi.fn().mockResolvedValue(undefined),
|
|
},
|
|
},
|
|
} as unknown as App;
|
|
}
|
|
|
|
function buildRequest(overrides?: Partial<Record<string, unknown>>) {
|
|
return {
|
|
id: "req-1",
|
|
request: {
|
|
command: "python3 -c \"print('slack exec approval smoke')\"",
|
|
turnSourceChannel: "slack",
|
|
turnSourceTo: "channel:C123ROOM",
|
|
turnSourceAccountId: "default",
|
|
turnSourceThreadId: "1712345678.123456",
|
|
sessionKey: "agent:main:slack:channel:c123room:thread:1712345678.123456",
|
|
...overrides,
|
|
},
|
|
createdAtMs: 0,
|
|
expiresAtMs: Date.now() + 60_000,
|
|
};
|
|
}
|
|
|
|
describe("SlackExecApprovalHandler", () => {
|
|
beforeAll(async () => {
|
|
({ SlackExecApprovalHandler } = await import("./exec-approvals.js"));
|
|
});
|
|
|
|
beforeEach(() => {
|
|
sendMessageSlackMock.mockReset();
|
|
sendMessageSlackMock.mockResolvedValue({
|
|
messageId: "1712345678.999999",
|
|
channelId: "D123APPROVER",
|
|
});
|
|
});
|
|
|
|
it("delivers DM-first approvals and only posts a short origin notice", async () => {
|
|
const app = buildApp();
|
|
const handler = new SlackExecApprovalHandler({
|
|
app,
|
|
accountId: "default",
|
|
config: buildConfig("dm").channels!.slack!.execApprovals!,
|
|
cfg: buildConfig("dm"),
|
|
});
|
|
|
|
await handler.handleApprovalRequested(buildRequest());
|
|
|
|
expect(sendMessageSlackMock).toHaveBeenCalledTimes(2);
|
|
expect(sendMessageSlackMock).toHaveBeenNthCalledWith(
|
|
1,
|
|
"channel:C123ROOM",
|
|
"Approval required. I sent approval DMs to the approvers for this account.",
|
|
expect.objectContaining({
|
|
accountId: "default",
|
|
threadTs: "1712345678.123456",
|
|
}),
|
|
);
|
|
expect(sendMessageSlackMock).toHaveBeenNthCalledWith(
|
|
2,
|
|
"user:U123APPROVER",
|
|
expect.stringContaining("Exec approval required"),
|
|
expect.objectContaining({
|
|
accountId: "default",
|
|
blocks: expect.arrayContaining([expect.objectContaining({ type: "actions" })]),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("does not post a redundant DM redirect notice when the origin is already the approver DM", async () => {
|
|
const app = buildApp();
|
|
const handler = new SlackExecApprovalHandler({
|
|
app,
|
|
accountId: "default",
|
|
config: buildConfig("dm").channels!.slack!.execApprovals!,
|
|
cfg: buildConfig("dm"),
|
|
});
|
|
|
|
await handler.handleApprovalRequested(
|
|
buildRequest({
|
|
turnSourceTo: "user:U123APPROVER",
|
|
turnSourceThreadId: undefined,
|
|
sessionKey: "agent:main:slack:direct:U123APPROVER",
|
|
}),
|
|
);
|
|
|
|
expect(sendMessageSlackMock).toHaveBeenCalledTimes(1);
|
|
expect(sendMessageSlackMock).toHaveBeenCalledWith(
|
|
"user:U123APPROVER",
|
|
expect.stringContaining("Exec approval required"),
|
|
expect.objectContaining({
|
|
blocks: expect.arrayContaining([expect.objectContaining({ type: "actions" })]),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("omits allow-always when exec approvals disallow it", async () => {
|
|
const app = buildApp();
|
|
const handler = new SlackExecApprovalHandler({
|
|
app,
|
|
accountId: "default",
|
|
config: buildConfig("dm").channels!.slack!.execApprovals!,
|
|
cfg: buildConfig("dm"),
|
|
});
|
|
|
|
await handler.handleApprovalRequested(
|
|
buildRequest({
|
|
ask: "always",
|
|
allowedDecisions: ["allow-once", "deny"],
|
|
}),
|
|
);
|
|
|
|
const dmCall = sendMessageSlackMock.mock.calls.find(([to]) => to === "user:U123APPROVER");
|
|
const blocks = dmCall?.[2]?.blocks as Array<Record<string, unknown>> | undefined;
|
|
const actionsBlock = blocks?.find((block) => block.type === "actions");
|
|
const buttons = Array.isArray(actionsBlock?.elements) ? actionsBlock.elements : [];
|
|
const buttonTexts = buttons.map((button) =>
|
|
typeof button === "object" && button && typeof button.text === "object" && button.text
|
|
? String((button.text as { text?: unknown }).text ?? "")
|
|
: "",
|
|
);
|
|
|
|
expect(buttonTexts).toContain("Allow Once");
|
|
expect(buttonTexts).toContain("Deny");
|
|
expect(buttonTexts).not.toContain("Allow Always");
|
|
});
|
|
|
|
it("updates the pending approval card in place after resolution", async () => {
|
|
const app = buildApp();
|
|
const update = app.client.chat.update as ReturnType<typeof vi.fn>;
|
|
const handler = new SlackExecApprovalHandler({
|
|
app,
|
|
accountId: "default",
|
|
config: buildConfig("dm").channels!.slack!.execApprovals!,
|
|
cfg: buildConfig("dm"),
|
|
});
|
|
|
|
await handler.handleApprovalRequested(
|
|
buildRequest({
|
|
turnSourceTo: "user:U123APPROVER",
|
|
turnSourceThreadId: undefined,
|
|
sessionKey: "agent:main:slack:direct:U123APPROVER",
|
|
}),
|
|
);
|
|
await handler.handleApprovalResolved({
|
|
id: "req-1",
|
|
decision: "allow-once",
|
|
resolvedBy: "U123APPROVER",
|
|
request: buildRequest().request,
|
|
ts: Date.now(),
|
|
});
|
|
|
|
expect(update).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
channel: "D123APPROVER",
|
|
ts: "1712345678.999999",
|
|
text: expect.stringContaining("Exec approval: Allowed once"),
|
|
blocks: expect.not.arrayContaining([expect.objectContaining({ type: "actions" })]),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("does not treat allowFrom senders as approvers", async () => {
|
|
const app = buildApp();
|
|
const cfg = buildConfig("dm", {
|
|
allowFrom: ["U123APPROVER"],
|
|
execApprovals: { enabled: true, target: "dm" },
|
|
});
|
|
const handler = new SlackExecApprovalHandler({
|
|
app,
|
|
accountId: "default",
|
|
config: cfg.channels!.slack!.execApprovals!,
|
|
cfg,
|
|
});
|
|
|
|
expect(handler.shouldHandle(buildRequest())).toBe(false);
|
|
});
|
|
|
|
it("accepts commands.ownerAllowFrom as exec approver fallback", async () => {
|
|
const app = buildApp();
|
|
const cfg = {
|
|
...buildConfig("dm", {
|
|
execApprovals: { enabled: true, target: "dm" },
|
|
}),
|
|
commands: { ownerAllowFrom: ["slack:U123APPROVER"] },
|
|
} as OpenClawConfig;
|
|
const handler = new SlackExecApprovalHandler({
|
|
app,
|
|
accountId: "default",
|
|
config: cfg.channels!.slack!.execApprovals!,
|
|
cfg,
|
|
});
|
|
|
|
expect(handler.shouldHandle(buildRequest())).toBe(true);
|
|
});
|
|
});
|