perf(test): split reply command coverage

This commit is contained in:
Peter Steinberger
2026-04-06 04:58:21 +01:00
parent e47e72e3ca
commit b40e28f76e
5 changed files with 1218 additions and 861 deletions

View File

@@ -0,0 +1,999 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ChannelPlugin } from "../../channels/plugins/types.js";
import type { OpenClawConfig } from "../../config/config.js";
import { resolveApprovalApprovers } from "../../plugin-sdk/approval-approvers.js";
import {
createApproverRestrictedNativeApprovalAdapter,
createResolvedApproverActionAuthAdapter,
} from "../../plugin-sdk/approval-runtime.js";
import { setActivePluginRegistry } from "../../plugins/runtime.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
import {
createChannelTestPluginBase,
createTestRegistry,
} from "../../test-utils/channel-plugins.js";
import { handleApproveCommand } from "./commands-approve.js";
import type { HandleCommandsParams } from "./commands-types.js";
const callGatewayMock = vi.hoisted(() => vi.fn());
vi.mock("../../gateway/call.js", () => ({
callGateway: callGatewayMock,
}));
vi.mock("../../globals.js", () => ({
logVerbose: vi.fn(),
}));
function normalizeDiscordDirectApproverId(value: string | number): string | undefined {
const normalized = String(value)
.trim()
.replace(/^(discord|user|pk):/i, "")
.replace(/^<@!?(\d+)>$/, "$1")
.toLowerCase();
return normalized || undefined;
}
function getDiscordExecApprovalApproversForTests(params: { cfg: OpenClawConfig }): string[] {
const discord = params.cfg.channels?.discord;
return resolveApprovalApprovers({
explicit: discord?.execApprovals?.approvers,
allowFrom: discord?.allowFrom,
extraAllowFrom: discord?.dm?.allowFrom,
defaultTo: discord?.defaultTo,
normalizeApprover: normalizeDiscordDirectApproverId,
normalizeDefaultTo: (value) => normalizeDiscordDirectApproverId(value),
});
}
const discordNativeApprovalAdapterForTests = createApproverRestrictedNativeApprovalAdapter({
channel: "discord",
channelLabel: "Discord",
listAccountIds: () => [DEFAULT_ACCOUNT_ID],
hasApprovers: ({ cfg }) => getDiscordExecApprovalApproversForTests({ cfg }).length > 0,
isExecAuthorizedSender: ({ cfg, senderId }) => {
const normalizedSenderId =
senderId === undefined || senderId === null
? undefined
: normalizeDiscordDirectApproverId(senderId);
return Boolean(
normalizedSenderId &&
getDiscordExecApprovalApproversForTests({ cfg }).includes(normalizedSenderId),
);
},
isNativeDeliveryEnabled: ({ cfg }) =>
Boolean(cfg.channels?.discord?.execApprovals?.enabled) &&
getDiscordExecApprovalApproversForTests({ cfg }).length > 0,
resolveNativeDeliveryMode: ({ cfg }) => cfg.channels?.discord?.execApprovals?.target ?? "dm",
});
const discordApproveTestPlugin: ChannelPlugin = {
...createChannelTestPluginBase({
id: "discord",
label: "Discord",
docsPath: "/channels/discord",
capabilities: {
chatTypes: ["direct", "group", "thread"],
reactions: true,
threads: true,
nativeCommands: true,
},
}),
auth: discordNativeApprovalAdapterForTests.auth,
};
const slackApproveTestPlugin: ChannelPlugin = {
...createChannelTestPluginBase({
id: "slack",
label: "Slack",
docsPath: "/channels/slack",
capabilities: {
chatTypes: ["direct", "group", "thread"],
reactions: true,
threads: true,
nativeCommands: true,
},
}),
};
const signalApproveTestPlugin: ChannelPlugin = {
...createChannelTestPluginBase({
id: "signal",
label: "Signal",
docsPath: "/channels/signal",
capabilities: {
chatTypes: ["direct", "group"],
reactions: true,
media: true,
nativeCommands: true,
},
}),
auth: createResolvedApproverActionAuthAdapter({
channelLabel: "Signal",
resolveApprovers: ({ cfg, accountId }) => {
const signal = accountId ? cfg.channels?.signal?.accounts?.[accountId] : cfg.channels?.signal;
return resolveApprovalApprovers({
allowFrom: signal?.allowFrom,
defaultTo: signal?.defaultTo,
normalizeApprover: (value) => String(value).trim() || undefined,
});
},
}),
};
type TelegramTestAccountConfig = {
enabled?: boolean;
allowFrom?: Array<string | number>;
execApprovals?: {
enabled?: boolean;
approvers?: string[];
target?: "dm" | "channel" | "both";
};
};
type TelegramTestSectionConfig = TelegramTestAccountConfig & {
defaultAccount?: string;
accounts?: Record<string, TelegramTestAccountConfig>;
};
function listConfiguredTelegramAccountIds(cfg: OpenClawConfig): string[] {
const channel = cfg.channels?.telegram as TelegramTestSectionConfig | undefined;
const accountIds = Object.keys(channel?.accounts ?? {});
if (accountIds.length > 0) {
return accountIds;
}
if (!channel) {
return [];
}
const { accounts: _accounts, defaultAccount: _defaultAccount, ...base } = channel;
return Object.values(base).some((value) => value !== undefined) ? [DEFAULT_ACCOUNT_ID] : [];
}
function resolveTelegramTestAccount(
cfg: OpenClawConfig,
accountId?: string | null,
): TelegramTestAccountConfig {
const resolvedAccountId = normalizeAccountId(accountId);
const channel = cfg.channels?.telegram as TelegramTestSectionConfig | undefined;
const scoped = channel?.accounts?.[resolvedAccountId];
const base = resolvedAccountId === DEFAULT_ACCOUNT_ID ? channel : undefined;
return {
...base,
...scoped,
enabled:
typeof scoped?.enabled === "boolean"
? scoped.enabled
: typeof channel?.enabled === "boolean"
? channel.enabled
: true,
};
}
function stripTelegramInternalPrefixes(value: string): string {
let trimmed = value.trim();
let strippedTelegramPrefix = false;
while (true) {
const next = (() => {
if (/^(telegram|tg):/i.test(trimmed)) {
strippedTelegramPrefix = true;
return trimmed.replace(/^(telegram|tg):/i, "").trim();
}
if (strippedTelegramPrefix && /^group:/i.test(trimmed)) {
return trimmed.replace(/^group:/i, "").trim();
}
return trimmed;
})();
if (next === trimmed) {
return trimmed;
}
trimmed = next;
}
}
function normalizeTelegramDirectApproverId(value: string | number): string | undefined {
const normalized = stripTelegramInternalPrefixes(String(value));
if (!normalized || normalized.startsWith("-")) {
return undefined;
}
return normalized;
}
function getTelegramExecApprovalApprovers(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): string[] {
const account = resolveTelegramTestAccount(params.cfg, params.accountId);
return resolveApprovalApprovers({
explicit: account.execApprovals?.approvers,
allowFrom: account.allowFrom,
normalizeApprover: normalizeTelegramDirectApproverId,
});
}
function isTelegramExecApprovalTargetRecipient(params: {
cfg: OpenClawConfig;
senderId?: string | null;
accountId?: string | null;
}): boolean {
const senderId = params.senderId?.trim();
const execApprovals = params.cfg.approvals?.exec;
if (
!senderId ||
execApprovals?.enabled !== true ||
(execApprovals.mode !== "targets" && execApprovals.mode !== "both")
) {
return false;
}
const accountId = params.accountId ? normalizeAccountId(params.accountId) : undefined;
return (execApprovals.targets ?? []).some((target) => {
if (target.channel?.trim().toLowerCase() !== "telegram") {
return false;
}
if (accountId && target.accountId && normalizeAccountId(target.accountId) !== accountId) {
return false;
}
const to = target.to ? normalizeTelegramDirectApproverId(target.to) : undefined;
return Boolean(to && to === senderId);
});
}
function isTelegramExecApprovalAuthorizedSender(params: {
cfg: OpenClawConfig;
accountId?: string | null;
senderId?: string | null;
}): boolean {
const senderId = params.senderId ? normalizeTelegramDirectApproverId(params.senderId) : undefined;
if (!senderId) {
return false;
}
return (
getTelegramExecApprovalApprovers(params).includes(senderId) ||
isTelegramExecApprovalTargetRecipient(params)
);
}
function isTelegramExecApprovalClientEnabled(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): boolean {
const config = resolveTelegramTestAccount(params.cfg, params.accountId).execApprovals;
return Boolean(config?.enabled && getTelegramExecApprovalApprovers(params).length > 0);
}
function resolveTelegramExecApprovalTarget(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): "dm" | "channel" | "both" {
return resolveTelegramTestAccount(params.cfg, params.accountId).execApprovals?.target ?? "dm";
}
const telegramNativeApprovalAdapter = createApproverRestrictedNativeApprovalAdapter({
channel: "telegram",
channelLabel: "Telegram",
listAccountIds: listConfiguredTelegramAccountIds,
hasApprovers: ({ cfg, accountId }) =>
getTelegramExecApprovalApprovers({ cfg, accountId }).length > 0,
isExecAuthorizedSender: isTelegramExecApprovalAuthorizedSender,
isPluginAuthorizedSender: ({ cfg, accountId, senderId }) => {
const normalizedSenderId = senderId?.trim();
return Boolean(
normalizedSenderId &&
getTelegramExecApprovalApprovers({ cfg, accountId }).includes(normalizedSenderId),
);
},
isNativeDeliveryEnabled: isTelegramExecApprovalClientEnabled,
resolveNativeDeliveryMode: resolveTelegramExecApprovalTarget,
requireMatchingTurnSourceChannel: true,
});
const telegramApproveTestPlugin: ChannelPlugin = {
...createChannelTestPluginBase({
id: "telegram",
label: "Telegram",
docsPath: "/channels/telegram",
capabilities: {
chatTypes: ["direct", "group", "channel", "thread"],
reactions: true,
threads: true,
media: true,
polls: true,
nativeCommands: true,
blockStreaming: true,
},
config: {
listAccountIds: listConfiguredTelegramAccountIds,
resolveAccount: (cfg, accountId) => resolveTelegramTestAccount(cfg, accountId),
defaultAccountId: (cfg) =>
(cfg.channels?.telegram as TelegramTestSectionConfig | undefined)?.defaultAccount ??
DEFAULT_ACCOUNT_ID,
},
}),
auth: telegramNativeApprovalAdapter.auth,
approvalCapability: {
resolveApproveCommandBehavior: ({ cfg, accountId, senderId, approvalKind }) => {
if (approvalKind !== "exec") {
return undefined;
}
if (isTelegramExecApprovalClientEnabled({ cfg, accountId })) {
return undefined;
}
if (isTelegramExecApprovalTargetRecipient({ cfg, accountId, senderId })) {
return undefined;
}
if (
isTelegramExecApprovalAuthorizedSender({ cfg, accountId, senderId }) &&
!getTelegramExecApprovalApprovers({ cfg, accountId }).includes(senderId?.trim() ?? "")
) {
return undefined;
}
return {
kind: "reply",
text: "❌ Telegram exec approvals are not enabled for this bot account.",
} as const;
},
},
};
function setApprovePluginRegistry(): void {
setActivePluginRegistry(
createTestRegistry([
{ pluginId: "discord", plugin: discordApproveTestPlugin, source: "test" },
{ pluginId: "slack", plugin: slackApproveTestPlugin, source: "test" },
{ pluginId: "signal", plugin: signalApproveTestPlugin, source: "test" },
{ pluginId: "telegram", plugin: telegramApproveTestPlugin, source: "test" },
]),
);
}
function buildApproveParams(
commandBodyNormalized: string,
cfg: OpenClawConfig,
ctxOverrides?: {
Provider?: string;
Surface?: string;
SenderId?: string;
GatewayClientScopes?: string[];
AccountId?: string;
},
): HandleCommandsParams {
const provider = ctxOverrides?.Provider ?? "whatsapp";
return {
cfg,
ctx: {
Provider: provider,
Surface: ctxOverrides?.Surface ?? provider,
CommandSource: "text",
SenderId: ctxOverrides?.SenderId,
GatewayClientScopes: ctxOverrides?.GatewayClientScopes,
AccountId: ctxOverrides?.AccountId,
},
command: {
commandBodyNormalized,
isAuthorizedSender: true,
senderId: ctxOverrides?.SenderId ?? "owner",
channel: provider,
channelId: provider,
},
} as unknown as HandleCommandsParams;
}
describe("handleApproveCommand", () => {
beforeEach(() => {
vi.clearAllMocks();
setApprovePluginRegistry();
});
function createTelegramApproveCfg(
execApprovals: {
enabled: true;
approvers: string[];
target: "dm";
} | null = { enabled: true, approvers: ["123"], target: "dm" },
): OpenClawConfig {
return {
commands: { text: true },
channels: {
telegram: {
allowFrom: ["*"],
...(execApprovals ? { execApprovals } : {}),
},
},
} as OpenClawConfig;
}
function createDiscordApproveCfg(
execApprovals: {
enabled: boolean;
approvers: string[];
target: "dm" | "channel" | "both";
} | null = { enabled: true, approvers: ["123"], target: "channel" },
): OpenClawConfig {
return {
commands: { text: true },
channels: {
discord: {
allowFrom: ["*"],
...(execApprovals ? { execApprovals } : {}),
},
},
} as OpenClawConfig;
}
it("rejects invalid usage", async () => {
const result = await handleApproveCommand(
buildApproveParams("/approve", {
commands: { text: true },
channels: { whatsapp: { allowFrom: ["*"] } },
} as OpenClawConfig),
true,
);
expect(result?.shouldContinue).toBe(false);
expect(result?.reply?.text).toContain("Usage: /approve");
});
it("submits approval", async () => {
callGatewayMock.mockResolvedValue({ ok: true });
const result = await handleApproveCommand(
buildApproveParams(
"/approve abc allow-once",
{
commands: { text: true },
channels: { whatsapp: { allowFrom: ["*"] } },
} as OpenClawConfig,
{ SenderId: "123" },
),
true,
);
expect(result?.shouldContinue).toBe(false);
expect(result?.reply?.text).toContain("Approval allow-once submitted");
expect(callGatewayMock).toHaveBeenCalledWith(
expect.objectContaining({
method: "exec.approval.resolve",
params: { id: "abc", decision: "allow-once" },
}),
);
});
it("accepts bare approve text for Slack-style manual approvals", async () => {
callGatewayMock.mockResolvedValue({ ok: true });
const result = await handleApproveCommand(
buildApproveParams(
"approve abc allow-once",
{
commands: { text: true },
channels: { slack: { allowFrom: ["*"] } },
} as OpenClawConfig,
{
Provider: "slack",
Surface: "slack",
SenderId: "U123",
},
),
true,
);
expect(result?.shouldContinue).toBe(false);
expect(result?.reply?.text).toContain("Approval allow-once submitted");
expect(callGatewayMock).toHaveBeenCalledWith(
expect.objectContaining({
method: "exec.approval.resolve",
params: { id: "abc", decision: "allow-once" },
}),
);
});
it("accepts Telegram /approve from configured approvers even when chat access is otherwise blocked", async () => {
const params = buildApproveParams("/approve abc12345 allow-once", createTelegramApproveCfg(), {
Provider: "telegram",
Surface: "telegram",
SenderId: "123",
});
params.command.isAuthorizedSender = false;
callGatewayMock.mockResolvedValue({ ok: true });
const result = await handleApproveCommand(params, true);
expect(result?.shouldContinue).toBe(false);
expect(result?.reply?.text).toContain("Approval allow-once submitted");
expect(callGatewayMock).toHaveBeenCalledWith(
expect.objectContaining({
method: "exec.approval.resolve",
params: { id: "abc12345", decision: "allow-once" },
}),
);
});
it("honors the configured default account for omitted-account /approve auth", async () => {
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "telegram",
plugin: telegramApproveTestPlugin,
source: "test",
},
]),
);
callGatewayMock.mockResolvedValue({ ok: true });
const params = buildApproveParams(
"/approve abc12345 allow-once",
{
commands: { text: true },
channels: {
telegram: {
defaultAccount: "work",
allowFrom: ["*"],
accounts: {
work: {
execApprovals: { enabled: true, approvers: ["123"], target: "dm" },
},
},
},
},
} as OpenClawConfig,
{
Provider: "telegram",
Surface: "telegram",
SenderId: "123",
AccountId: undefined,
},
);
params.command.isAuthorizedSender = false;
const result = await handleApproveCommand(params, true);
expect(result?.shouldContinue).toBe(false);
expect(result?.reply?.text).toContain("Approval allow-once submitted");
expect(callGatewayMock).toHaveBeenCalledWith(
expect.objectContaining({
method: "exec.approval.resolve",
params: { id: "abc12345", decision: "allow-once" },
}),
);
});
it("accepts Signal /approve from configured approvers even when chat access is otherwise blocked", async () => {
const params = buildApproveParams(
"/approve abc12345 allow-once",
{
commands: { text: true },
channels: {
signal: {
allowFrom: ["+15551230000"],
},
},
} as OpenClawConfig,
{
Provider: "signal",
Surface: "signal",
SenderId: "+15551230000",
},
);
params.command.isAuthorizedSender = false;
callGatewayMock.mockResolvedValue({ ok: true });
const result = await handleApproveCommand(params, true);
expect(result?.shouldContinue).toBe(false);
expect(result?.reply?.text).toContain("Approval allow-once submitted");
expect(callGatewayMock).toHaveBeenCalledWith(
expect.objectContaining({
method: "exec.approval.resolve",
params: { id: "abc12345", decision: "allow-once" },
}),
);
});
it("does not treat implicit default approval auth as a bypass for unauthorized senders", async () => {
const params = buildApproveParams(
"/approve abc12345 allow-once",
{
commands: { text: true },
} as OpenClawConfig,
{
Provider: "webchat",
Surface: "webchat",
SenderId: "123",
},
);
params.command.isAuthorizedSender = false;
const result = await handleApproveCommand(params, true);
expect(result?.shouldContinue).toBe(false);
expect(result?.reply).toBeUndefined();
expect(callGatewayMock).not.toHaveBeenCalled();
});
it("does not treat implicit same-chat approval auth as a bypass for unauthorized senders", async () => {
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "slack",
plugin: {
...createChannelTestPluginBase({ id: "slack", label: "Slack" }),
auth: {
authorizeActorAction: () => ({ authorized: true }),
getActionAvailabilityState: () => ({ kind: "disabled" }),
},
},
source: "test",
},
]),
);
const params = buildApproveParams(
"/approve abc12345 allow-once",
{
commands: { text: true },
channels: { slack: { allowFrom: ["*"] } },
} as OpenClawConfig,
{
Provider: "slack",
Surface: "slack",
SenderId: "U123",
},
);
params.command.isAuthorizedSender = false;
const result = await handleApproveCommand(params, true);
expect(result?.shouldContinue).toBe(false);
expect(result?.reply).toBeUndefined();
expect(callGatewayMock).not.toHaveBeenCalled();
});
it("accepts Telegram /approve from exec target recipients when native approvals are disabled", async () => {
const params = buildApproveParams(
"/approve abc12345 allow-once",
{
commands: { text: true },
approvals: {
exec: {
enabled: true,
mode: "targets",
targets: [{ channel: "telegram", to: "123" }],
},
},
channels: {
telegram: {
allowFrom: ["*"],
},
},
} as OpenClawConfig,
{
Provider: "telegram",
Surface: "telegram",
SenderId: "123",
},
);
params.command.isAuthorizedSender = false;
callGatewayMock.mockResolvedValue({ ok: true });
const result = await handleApproveCommand(params, true);
expect(result?.shouldContinue).toBe(false);
expect(result?.reply?.text).toContain("Approval allow-once submitted");
expect(callGatewayMock).toHaveBeenCalledWith(
expect.objectContaining({
method: "exec.approval.resolve",
params: { id: "abc12345", decision: "allow-once" },
}),
);
});
it("requires configured Discord approvers for exec approvals", async () => {
for (const testCase of [
{
name: "discord no approver policy",
cfg: createDiscordApproveCfg(null),
senderId: "123",
expectedText: "not authorized to approve",
expectedGatewayCalls: 0,
},
{
name: "discord non approver",
cfg: createDiscordApproveCfg({ enabled: true, approvers: ["999"], target: "channel" }),
senderId: "123",
expectedText: "not authorized to approve",
expectedGatewayCalls: 0,
},
{
name: "discord approver with rich client disabled",
cfg: createDiscordApproveCfg({ enabled: false, approvers: ["123"], target: "channel" }),
senderId: "123",
expectedText: "Approval allow-once submitted",
expectedGatewayCalls: 1,
expectedMethod: "exec.approval.resolve",
},
{
name: "discord approver",
cfg: createDiscordApproveCfg({ enabled: true, approvers: ["123"], target: "channel" }),
senderId: "123",
expectedText: "Approval allow-once submitted",
expectedGatewayCalls: 1,
expectedMethod: "exec.approval.resolve",
},
] as const) {
callGatewayMock.mockReset();
if (testCase.expectedGatewayCalls > 0) {
callGatewayMock.mockResolvedValue({ ok: true });
}
const result = await handleApproveCommand(
buildApproveParams("/approve abc12345 allow-once", testCase.cfg, {
Provider: "discord",
Surface: "discord",
SenderId: testCase.senderId,
}),
true,
);
expect(result?.shouldContinue, testCase.name).toBe(false);
expect(result?.reply?.text, testCase.name).toContain(testCase.expectedText);
expect(callGatewayMock, testCase.name).toHaveBeenCalledTimes(testCase.expectedGatewayCalls);
if ("expectedMethod" in testCase) {
expect(callGatewayMock, testCase.name).toHaveBeenCalledWith(
expect.objectContaining({
method: testCase.expectedMethod,
params: { id: "abc12345", decision: "allow-once" },
}),
);
}
}
});
it("rejects legacy unprefixed plugin approval fallback on Discord before exec fallback", async () => {
for (const testCase of [
{
name: "discord legacy plugin approval with exec approvals disabled",
cfg: createDiscordApproveCfg(null),
senderId: "123",
},
{
name: "discord legacy plugin approval for non approver",
cfg: createDiscordApproveCfg({ enabled: true, approvers: ["999"], target: "channel" }),
senderId: "123",
},
] as const) {
callGatewayMock.mockReset();
callGatewayMock.mockResolvedValue({ ok: true });
const result = await handleApproveCommand(
buildApproveParams("/approve legacy-plugin-123 allow-once", testCase.cfg, {
Provider: "discord",
Surface: "discord",
SenderId: testCase.senderId,
}),
true,
);
expect(result?.shouldContinue, testCase.name).toBe(false);
expect(result?.reply?.text, testCase.name).toContain("not authorized to approve");
expect(callGatewayMock, testCase.name).not.toHaveBeenCalled();
}
});
it("preserves legacy unprefixed plugin approval fallback on Discord", async () => {
callGatewayMock.mockRejectedValueOnce(new Error("unknown or expired approval id"));
callGatewayMock.mockResolvedValueOnce({ ok: true });
const result = await handleApproveCommand(
buildApproveParams(
"/approve legacy-plugin-123 allow-once",
createDiscordApproveCfg({ enabled: true, approvers: ["123"], target: "channel" }),
{
Provider: "discord",
Surface: "discord",
SenderId: "123",
},
),
true,
);
expect(result?.shouldContinue).toBe(false);
expect(result?.reply?.text).toContain("Approval allow-once submitted");
expect(callGatewayMock).toHaveBeenCalledTimes(2);
expect(callGatewayMock).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
method: "plugin.approval.resolve",
params: { id: "legacy-plugin-123", decision: "allow-once" },
}),
);
});
it("returns the underlying not-found error for plugin-only approval routing", async () => {
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "matrix",
plugin: {
...createChannelTestPluginBase({ id: "matrix", label: "Matrix" }),
auth: {
authorizeActorAction: ({ approvalKind }: { approvalKind: "exec" | "plugin" }) =>
approvalKind === "plugin"
? { authorized: true }
: {
authorized: false,
reason: "❌ You are not authorized to approve exec requests on Matrix.",
},
},
},
source: "test",
},
]),
);
callGatewayMock.mockRejectedValueOnce(new Error("unknown or expired approval id"));
const result = await handleApproveCommand(
buildApproveParams(
"/approve abc123 allow-once",
{
commands: { text: true },
channels: { matrix: { allowFrom: ["*"] } },
} as OpenClawConfig,
{
Provider: "matrix",
Surface: "matrix",
SenderId: "123",
},
),
true,
);
expect(result?.shouldContinue).toBe(false);
expect(result?.reply?.text).toContain("Failed to submit approval");
expect(result?.reply?.text).toContain("unknown or expired approval id");
expect(callGatewayMock).toHaveBeenCalledTimes(1);
expect(callGatewayMock).toHaveBeenCalledWith(
expect.objectContaining({
method: "plugin.approval.resolve",
params: { id: "abc123", decision: "allow-once" },
}),
);
});
it("requires configured Discord approvers for plugin approvals", async () => {
for (const testCase of [
{
name: "discord plugin non approver",
cfg: createDiscordApproveCfg({ enabled: false, approvers: ["999"], target: "channel" }),
senderId: "123",
expectedText: "not authorized to approve plugin requests",
expectedGatewayCalls: 0,
},
{
name: "discord plugin approver",
cfg: createDiscordApproveCfg({ enabled: false, approvers: ["123"], target: "channel" }),
senderId: "123",
expectedText: "Approval allow-once submitted",
expectedGatewayCalls: 1,
},
] as const) {
callGatewayMock.mockReset();
if (testCase.expectedGatewayCalls > 0) {
callGatewayMock.mockResolvedValue({ ok: true });
}
const result = await handleApproveCommand(
buildApproveParams("/approve plugin:abc123 allow-once", testCase.cfg, {
Provider: "discord",
Surface: "discord",
SenderId: testCase.senderId,
}),
true,
);
expect(result?.shouldContinue, testCase.name).toBe(false);
expect(result?.reply?.text, testCase.name).toContain(testCase.expectedText);
expect(callGatewayMock, testCase.name).toHaveBeenCalledTimes(testCase.expectedGatewayCalls);
if (testCase.expectedGatewayCalls > 0) {
expect(callGatewayMock, testCase.name).toHaveBeenCalledWith(
expect.objectContaining({
method: "plugin.approval.resolve",
params: { id: "plugin:abc123", decision: "allow-once" },
}),
);
}
}
});
it("rejects unauthorized or invalid Telegram /approve variants", async () => {
for (const testCase of [
{
name: "different bot mention",
cfg: createTelegramApproveCfg(),
commandBody: "/approve@otherbot abc12345 allow-once",
ctx: {
Provider: "telegram",
Surface: "telegram",
SenderId: "123",
},
expectedText: "targets a different Telegram bot",
expectGatewayCalls: 0,
},
{
name: "unknown approval id",
cfg: createTelegramApproveCfg(),
commandBody: "/approve abc12345 allow-once",
ctx: {
Provider: "telegram",
Surface: "telegram",
SenderId: "123",
},
setup: () => callGatewayMock.mockRejectedValue(new Error("unknown or expired approval id")),
expectedText: "unknown or expired approval id",
expectGatewayCalls: 2,
},
{
name: "telegram disabled native delivery reports the channel-disabled message",
cfg: createTelegramApproveCfg(null),
commandBody: "/approve abc12345 allow-once",
ctx: {
Provider: "telegram",
Surface: "telegram",
SenderId: "123",
},
expectedText: "Telegram exec approvals are not enabled",
expectGatewayCalls: 0,
},
{
name: "non approver",
cfg: createTelegramApproveCfg({ enabled: true, approvers: ["999"], target: "dm" }),
commandBody: "/approve abc12345 allow-once",
ctx: {
Provider: "telegram",
Surface: "telegram",
SenderId: "123",
},
expectedText: "not authorized to approve",
expectGatewayCalls: 0,
},
] as const) {
callGatewayMock.mockReset();
testCase.setup?.();
const result = await handleApproveCommand(
buildApproveParams(testCase.commandBody, testCase.cfg, testCase.ctx),
true,
);
expect(result?.shouldContinue, testCase.name).toBe(false);
expect(result?.reply?.text, testCase.name).toContain(testCase.expectedText);
expect(callGatewayMock, testCase.name).toHaveBeenCalledTimes(testCase.expectGatewayCalls);
}
});
it("enforces gateway approval scopes", async () => {
const cfg = {
commands: { text: true },
} as OpenClawConfig;
for (const testCase of [
{
scopes: ["operator.write"],
expectedText: "requires operator.approvals",
expectedGatewayCalls: 0,
},
{
scopes: ["operator.approvals"],
expectedText: "Approval allow-once submitted",
expectedGatewayCalls: 1,
},
{
scopes: ["operator.admin"],
expectedText: "Approval allow-once submitted",
expectedGatewayCalls: 1,
},
] as const) {
callGatewayMock.mockReset();
callGatewayMock.mockResolvedValue({ ok: true });
const result = await handleApproveCommand(
buildApproveParams("/approve abc allow-once", cfg, {
Provider: "webchat",
Surface: "webchat",
GatewayClientScopes: [...testCase.scopes],
}),
true,
);
expect(result?.shouldContinue, String(testCase.scopes)).toBe(false);
expect(result?.reply?.text, String(testCase.scopes)).toContain(testCase.expectedText);
expect(callGatewayMock, String(testCase.scopes)).toHaveBeenCalledTimes(
testCase.expectedGatewayCalls,
);
if (testCase.expectedGatewayCalls > 0) {
expect(callGatewayMock, String(testCase.scopes)).toHaveBeenLastCalledWith(
expect.objectContaining({
method: "exec.approval.resolve",
params: { id: "abc", decision: "allow-once" },
}),
);
}
}
});
});

View File

@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import { parseConfigCommand } from "./config-commands.js";
import { parseDebugCommand } from "./debug-commands.js";
describe("config/debug command parsing", () => {
it("parses config/debug command actions and JSON payloads", () => {
const cases: Array<{
parse: (input: string) => unknown;
input: string;
expected: unknown;
}> = [
{ parse: parseConfigCommand, input: "/config", expected: { action: "show" } },
{
parse: parseConfigCommand,
input: "/config show",
expected: { action: "show", path: undefined },
},
{
parse: parseConfigCommand,
input: "/config show foo.bar",
expected: { action: "show", path: "foo.bar" },
},
{
parse: parseConfigCommand,
input: "/config get foo.bar",
expected: { action: "show", path: "foo.bar" },
},
{
parse: parseConfigCommand,
input: "/config unset foo.bar",
expected: { action: "unset", path: "foo.bar" },
},
{
parse: parseConfigCommand,
input: '/config set foo={"a":1}',
expected: { action: "set", path: "foo", value: { a: 1 } },
},
{ parse: parseDebugCommand, input: "/debug", expected: { action: "show" } },
{ parse: parseDebugCommand, input: "/debug show", expected: { action: "show" } },
{ parse: parseDebugCommand, input: "/debug reset", expected: { action: "reset" } },
{
parse: parseDebugCommand,
input: "/debug unset foo.bar",
expected: { action: "unset", path: "foo.bar" },
},
{
parse: parseDebugCommand,
input: '/debug set foo={"a":1}',
expected: { action: "set", path: "foo", value: { a: 1 } },
},
];
for (const testCase of cases) {
expect(testCase.parse(testCase.input)).toEqual(testCase.expected);
}
});
});

View File

@@ -0,0 +1,75 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import { handlePluginCommand } from "./commands-plugin.js";
import type { HandleCommandsParams } from "./commands-types.js";
const matchPluginCommandMock = vi.hoisted(() => vi.fn());
const executePluginCommandMock = vi.hoisted(() => vi.fn());
vi.mock("../../plugins/commands.js", () => ({
matchPluginCommand: matchPluginCommandMock,
executePluginCommand: executePluginCommandMock,
}));
function buildPluginParams(
commandBodyNormalized: string,
cfg: OpenClawConfig,
): HandleCommandsParams {
return {
cfg,
ctx: {
Provider: "whatsapp",
Surface: "whatsapp",
CommandSource: "text",
GatewayClientScopes: ["operator.write", "operator.pairing"],
AccountId: undefined,
},
command: {
commandBodyNormalized,
isAuthorizedSender: true,
senderId: "owner",
channel: "whatsapp",
channelId: "whatsapp",
from: "test-user",
to: "test-bot",
},
sessionKey: "agent:main:whatsapp:direct:test-user",
sessionEntry: {
sessionId: "session-plugin-command",
updatedAt: Date.now(),
},
} as unknown as HandleCommandsParams;
}
describe("handlePluginCommand", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("dispatches registered plugin commands with gateway scopes and session metadata", async () => {
matchPluginCommandMock.mockReturnValue({
command: { name: "card" },
args: "",
});
executePluginCommandMock.mockResolvedValue({ text: "from plugin" });
const result = await handlePluginCommand(
buildPluginParams("/card", {
commands: { text: true },
channels: { whatsapp: { allowFrom: ["*"] } },
} as OpenClawConfig),
true,
);
expect(result?.shouldContinue).toBe(false);
expect(result?.reply?.text).toBe("from plugin");
expect(executePluginCommandMock).toHaveBeenCalledWith(
expect.objectContaining({
gatewayClientScopes: ["operator.write", "operator.pairing"],
sessionKey: "agent:main:whatsapp:direct:test-user",
sessionId: "session-plugin-command",
commandBody: "/card",
}),
);
});
});

View File

@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
const ttsMocks = vi.hoisted(() => ({
getResolvedSpeechProviderConfig: vi.fn(),
@@ -34,9 +35,12 @@ const { handleTtsCommands } = await import("./commands-tts.js");
const PRIMARY_TTS_PROVIDER = "acme-speech";
const FALLBACK_TTS_PROVIDER = "backup-speech";
function buildTtsParams(commandBodyNormalized: string): Parameters<typeof handleTtsCommands>[0] {
function buildTtsParams(
commandBodyNormalized: string,
cfg: OpenClawConfig = {},
): Parameters<typeof handleTtsCommands>[0] {
return {
cfg: {},
cfg,
command: {
commandBodyNormalized,
isAuthorizedSender: true,
@@ -174,4 +178,15 @@ describe("handleTtsCommands status fallback reporting", () => {
`Attempt details: ${PRIMARY_TTS_PROVIDER}:failed(provider_error) 65ms, ${FALLBACK_TTS_PROVIDER}:success(ok) 175ms`,
);
});
it("treats bare /tts as status", async () => {
const result = await handleTtsCommands(
buildTtsParams("/tts", {
messages: { tts: { prefsPath: "/tmp/tts.json" } },
} as OpenClawConfig),
true,
);
expect(result?.shouldContinue).toBe(false);
expect(result?.reply?.text).toContain("TTS status");
});
});

File diff suppressed because it is too large Load Diff