test: dedupe extension channel fixtures

This commit is contained in:
Peter Steinberger
2026-03-26 17:59:05 +00:00
parent 48167a69b9
commit f29c1206cd
6 changed files with 269 additions and 354 deletions

View File

@@ -60,6 +60,51 @@ describe("broadcast dispatch", () => {
contentType: "video/mp4",
});
function createBroadcastConfig(): ClawdbotConfig {
return {
broadcast: { "oc-broadcast-group": ["susan", "main"] },
agents: { list: [{ id: "main" }, { id: "susan" }] },
channels: {
feishu: {
groups: {
"oc-broadcast-group": {
requireMention: true,
},
},
},
},
} as unknown as ClawdbotConfig;
}
function createBroadcastEvent(options: {
messageId: string;
text: string;
botMentioned?: boolean;
}): FeishuMessageEvent {
return {
sender: { sender_id: { open_id: "ou-sender" } },
message: {
message_id: options.messageId,
chat_id: "oc-broadcast-group",
chat_type: "group",
message_type: "text",
content: JSON.stringify({ text: options.text }),
...(options.botMentioned
? {
mentions: [
{
key: "@_user_1",
id: { open_id: "bot-open-id" },
name: "Bot",
tenant_key: "",
},
],
}
: {}),
},
};
}
beforeEach(() => {
vi.clearAllMocks();
mockResolveAgentRoute.mockReturnValue({
@@ -112,33 +157,12 @@ describe("broadcast dispatch", () => {
});
it("dispatches to all broadcast agents when bot is mentioned", async () => {
const cfg: ClawdbotConfig = {
broadcast: { "oc-broadcast-group": ["susan", "main"] },
agents: { list: [{ id: "main" }, { id: "susan" }] },
channels: {
feishu: {
groups: {
"oc-broadcast-group": {
requireMention: true,
},
},
},
},
} as unknown as ClawdbotConfig;
const event: FeishuMessageEvent = {
sender: { sender_id: { open_id: "ou-sender" } },
message: {
message_id: "msg-broadcast-mentioned",
chat_id: "oc-broadcast-group",
chat_type: "group",
message_type: "text",
content: JSON.stringify({ text: "hello @bot" }),
mentions: [
{ key: "@_user_1", id: { open_id: "bot-open-id" }, name: "Bot", tenant_key: "" },
],
},
};
const cfg = createBroadcastConfig();
const event = createBroadcastEvent({
messageId: "msg-broadcast-mentioned",
text: "hello @bot",
botMentioned: true,
});
await handleFeishuMessage({
cfg,
@@ -160,30 +184,11 @@ describe("broadcast dispatch", () => {
});
it("skips broadcast dispatch when bot is NOT mentioned (requireMention=true)", async () => {
const cfg: ClawdbotConfig = {
broadcast: { "oc-broadcast-group": ["susan", "main"] },
agents: { list: [{ id: "main" }, { id: "susan" }] },
channels: {
feishu: {
groups: {
"oc-broadcast-group": {
requireMention: true,
},
},
},
},
} as unknown as ClawdbotConfig;
const event: FeishuMessageEvent = {
sender: { sender_id: { open_id: "ou-sender" } },
message: {
message_id: "msg-broadcast-not-mentioned",
chat_id: "oc-broadcast-group",
chat_type: "group",
message_type: "text",
content: JSON.stringify({ text: "hello everyone" }),
},
};
const cfg = createBroadcastConfig();
const event = createBroadcastEvent({
messageId: "msg-broadcast-not-mentioned",
text: "hello everyone",
});
await handleFeishuMessage({
cfg,
@@ -197,30 +202,11 @@ describe("broadcast dispatch", () => {
});
it("skips broadcast dispatch when bot identity is unknown (requireMention=true)", async () => {
const cfg: ClawdbotConfig = {
broadcast: { "oc-broadcast-group": ["susan", "main"] },
agents: { list: [{ id: "main" }, { id: "susan" }] },
channels: {
feishu: {
groups: {
"oc-broadcast-group": {
requireMention: true,
},
},
},
},
} as unknown as ClawdbotConfig;
const event: FeishuMessageEvent = {
sender: { sender_id: { open_id: "ou-sender" } },
message: {
message_id: "msg-broadcast-unknown-bot-id",
chat_id: "oc-broadcast-group",
chat_type: "group",
message_type: "text",
content: JSON.stringify({ text: "hello everyone" }),
},
};
const cfg = createBroadcastConfig();
const event = createBroadcastEvent({
messageId: "msg-broadcast-unknown-bot-id",
text: "hello everyone",
});
await handleFeishuMessage({
cfg,

View File

@@ -61,6 +61,41 @@ function getDescribedActions(cfg: OpenClawConfig): string[] {
return [...(feishuPlugin.actions?.describeMessageTool?.({ cfg })?.actions ?? [])];
}
function createLegacyFeishuButtonCard(value: { command?: string; text?: string }) {
return {
schema: "2.0",
body: {
elements: [
{
tag: "action",
actions: [
{
tag: "button",
text: { tag: "plain_text", content: "Run /new" },
value,
},
],
},
],
},
};
}
async function expectLegacyFeishuCardPayloadRejected(cfg: OpenClawConfig, card: unknown) {
await expect(
feishuPlugin.actions?.handleAction?.({
action: "send",
params: { to: "chat:oc_group_1", card },
cfg,
accountId: undefined,
toolContext: {},
} as never),
).rejects.toThrow(
"Feishu card buttons that trigger text or commands must use structured interaction envelopes.",
);
expect(sendCardFeishuMock).not.toHaveBeenCalled();
}
describe("feishuPlugin.status.probeAccount", () => {
it("uses current account credentials for multi-account config", async () => {
const cfg = {
@@ -248,69 +283,17 @@ describe("feishuPlugin actions", () => {
});
it("rejects raw legacy card command payloads", async () => {
const legacyCard = {
schema: "2.0",
body: {
elements: [
{
tag: "action",
actions: [
{
tag: "button",
text: { tag: "plain_text", content: "Run /new" },
value: { command: "/new" },
},
],
},
],
},
};
await expect(
feishuPlugin.actions?.handleAction?.({
action: "send",
params: { to: "chat:oc_group_1", card: legacyCard },
cfg,
accountId: undefined,
toolContext: {},
} as never),
).rejects.toThrow(
"Feishu card buttons that trigger text or commands must use structured interaction envelopes.",
await expectLegacyFeishuCardPayloadRejected(
cfg,
createLegacyFeishuButtonCard({ command: "/new" }),
);
expect(sendCardFeishuMock).not.toHaveBeenCalled();
});
it("rejects raw legacy card text payloads", async () => {
const legacyCard = {
schema: "2.0",
body: {
elements: [
{
tag: "action",
actions: [
{
tag: "button",
text: { tag: "plain_text", content: "Run /new" },
value: { text: "/new" },
},
],
},
],
},
};
await expect(
feishuPlugin.actions?.handleAction?.({
action: "send",
params: { to: "chat:oc_group_1", card: legacyCard },
cfg,
accountId: undefined,
toolContext: {},
} as never),
).rejects.toThrow(
"Feishu card buttons that trigger text or commands must use structured interaction envelopes.",
await expectLegacyFeishuCardPayloadRejected(
cfg,
createLegacyFeishuButtonCard({ text: "/new" }),
);
expect(sendCardFeishuMock).not.toHaveBeenCalled();
});
it("allows non-button controls to carry text metadata values", async () => {

View File

@@ -58,6 +58,61 @@ function primeCommonDefaults() {
warnMissingProviderGroupPolicyFallbackOnce.mockReturnValue(undefined);
}
const baseAccessConfig = {
channels: { googlechat: {} },
commands: { useAccessGroups: true },
} as const;
const defaultSender = {
senderId: "users/alice",
senderName: "Alice",
senderEmail: "alice@example.com",
} as const;
function allowInboundGroupTraffic(options?: {
effectiveGroupAllowFrom?: string[];
effectiveWasMentioned?: boolean;
}) {
createChannelPairingController.mockReturnValue({
readAllowFromStore: vi.fn(async () => []),
issueChallenge: vi.fn(),
});
resolveDmGroupAccessWithLists.mockReturnValue({
decision: "allow",
effectiveAllowFrom: [],
effectiveGroupAllowFrom: options?.effectiveGroupAllowFrom ?? ["users/alice"],
});
resolveMentionGatingWithBypass.mockReturnValue({
shouldSkip: false,
effectiveWasMentioned: options?.effectiveWasMentioned ?? true,
});
}
async function applyInboundAccessPolicy(
overrides: Partial<
Parameters<
Awaited<typeof import("./monitor-access.js")>["applyGoogleChatInboundAccessPolicy"]
>[0]
>,
) {
const { applyGoogleChatInboundAccessPolicy } = await import("./monitor-access.js");
return applyGoogleChatInboundAccessPolicy({
account: {
accountId: "default",
config: {},
} as never,
config: baseAccessConfig as never,
core: createCore() as never,
space: { name: "spaces/AAA", displayName: "Team Room" } as never,
message: { annotations: [] } as never,
isGroup: true,
rawBody: "hello team",
logVerbose: vi.fn(),
...defaultSender,
...overrides,
} as never);
}
describe("googlechat inbound access policy", () => {
it("issues a pairing challenge for unauthorized DMs in pairing mode", async () => {
primeCommonDefaults();
@@ -120,27 +175,13 @@ describe("googlechat inbound access policy", () => {
it("allows group traffic when sender and mention gates pass", async () => {
primeCommonDefaults();
createChannelPairingController.mockReturnValue({
readAllowFromStore: vi.fn(async () => []),
issueChallenge: vi.fn(),
});
resolveDmGroupAccessWithLists.mockReturnValue({
decision: "allow",
effectiveAllowFrom: [],
effectiveGroupAllowFrom: ["users/alice"],
});
resolveMentionGatingWithBypass.mockReturnValue({
shouldSkip: false,
effectiveWasMentioned: true,
});
allowInboundGroupTraffic();
const core = createCore();
core.channel.commands.shouldComputeCommandAuthorized.mockReturnValue(true);
core.channel.commands.resolveCommandAuthorizedFromAuthorizers.mockReturnValue(true);
const { applyGoogleChatInboundAccessPolicy } = await import("./monitor-access.js");
await expect(
applyGoogleChatInboundAccessPolicy({
applyInboundAccessPolicy({
account: {
accountId: "default",
config: {
@@ -154,12 +195,7 @@ describe("googlechat inbound access policy", () => {
},
},
} as never,
config: {
channels: { googlechat: {} },
commands: { useAccessGroups: true },
} as never,
core: core as never,
space: { name: "spaces/AAA", displayName: "Team Room" } as never,
message: {
annotations: [
{
@@ -168,12 +204,6 @@ describe("googlechat inbound access policy", () => {
},
],
} as never,
isGroup: true,
senderId: "users/alice",
senderName: "Alice",
senderEmail: "alice@example.com",
rawBody: "hello team",
logVerbose: vi.fn(),
}),
).resolves.toEqual({
ok: true,
@@ -185,17 +215,8 @@ describe("googlechat inbound access policy", () => {
it("drops unauthorized group control commands", async () => {
primeCommonDefaults();
createChannelPairingController.mockReturnValue({
readAllowFromStore: vi.fn(async () => []),
issueChallenge: vi.fn(),
});
resolveDmGroupAccessWithLists.mockReturnValue({
decision: "allow",
effectiveAllowFrom: [],
allowInboundGroupTraffic({
effectiveGroupAllowFrom: [],
});
resolveMentionGatingWithBypass.mockReturnValue({
shouldSkip: false,
effectiveWasMentioned: false,
});
const core = createCore();
@@ -204,25 +225,9 @@ describe("googlechat inbound access policy", () => {
core.channel.commands.isControlCommandMessage.mockReturnValue(true);
const logVerbose = vi.fn();
const { applyGoogleChatInboundAccessPolicy } = await import("./monitor-access.js");
await expect(
applyGoogleChatInboundAccessPolicy({
account: {
accountId: "default",
config: {},
} as never,
config: {
channels: { googlechat: {} },
commands: { useAccessGroups: true },
} as never,
applyInboundAccessPolicy({
core: core as never,
space: { name: "spaces/AAA", displayName: "Team Room" } as never,
message: { annotations: [] } as never,
isGroup: true,
senderId: "users/alice",
senderName: "Alice",
senderEmail: "alice@example.com",
rawBody: "/admin",
logVerbose,
}),
@@ -233,25 +238,11 @@ describe("googlechat inbound access policy", () => {
it("does not match group policy by mutable space displayName when the stable id differs", async () => {
primeCommonDefaults();
createChannelPairingController.mockReturnValue({
readAllowFromStore: vi.fn(async () => []),
issueChallenge: vi.fn(),
});
resolveDmGroupAccessWithLists.mockReturnValue({
decision: "allow",
effectiveAllowFrom: [],
effectiveGroupAllowFrom: ["users/alice"],
});
resolveMentionGatingWithBypass.mockReturnValue({
shouldSkip: false,
effectiveWasMentioned: true,
});
allowInboundGroupTraffic();
const logVerbose = vi.fn();
const { applyGoogleChatInboundAccessPolicy } = await import("./monitor-access.js");
await expect(
applyGoogleChatInboundAccessPolicy({
applyInboundAccessPolicy({
account: {
accountId: "default",
config: {
@@ -264,10 +255,6 @@ describe("googlechat inbound access policy", () => {
},
},
} as never,
config: {
channels: { googlechat: {} },
commands: { useAccessGroups: true },
} as never,
core: createCore() as never,
space: { name: "spaces/BBB", displayName: "Finance Ops" } as never,
message: {
@@ -278,10 +265,6 @@ describe("googlechat inbound access policy", () => {
},
],
} as never,
isGroup: true,
senderId: "users/alice",
senderName: "Alice",
senderEmail: "alice@example.com",
rawBody: "show quarter close status",
logVerbose,
}),
@@ -301,25 +284,11 @@ describe("googlechat inbound access policy", () => {
groupPolicy: "open",
providerMissingFallbackApplied: false,
});
createChannelPairingController.mockReturnValue({
readAllowFromStore: vi.fn(async () => []),
issueChallenge: vi.fn(),
});
resolveDmGroupAccessWithLists.mockReturnValue({
decision: "allow",
effectiveAllowFrom: [],
effectiveGroupAllowFrom: ["users/alice"],
});
resolveMentionGatingWithBypass.mockReturnValue({
shouldSkip: false,
effectiveWasMentioned: true,
});
allowInboundGroupTraffic();
const logVerbose = vi.fn();
const { applyGoogleChatInboundAccessPolicy } = await import("./monitor-access.js");
await expect(
applyGoogleChatInboundAccessPolicy({
applyInboundAccessPolicy({
account: {
accountId: "default",
config: {
@@ -335,17 +304,8 @@ describe("googlechat inbound access policy", () => {
},
},
} as never,
config: {
channels: { googlechat: {} },
commands: { useAccessGroups: true },
} as never,
core: createCore() as never,
space: { name: "spaces/BBB", displayName: "Finance Ops" } as never,
message: { annotations: [] } as never,
isGroup: true,
senderId: "users/alice",
senderName: "Alice",
senderEmail: "alice@example.com",
rawBody: "show quarter close status",
logVerbose,
}),

View File

@@ -1,5 +1,7 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import { describe, expect, it, vi } from "vitest";
import type { WebhookTarget } from "./monitor-types.js";
import type { GoogleChatEvent } from "./types.js";
const readJsonWebhookBodyOrReject = vi.hoisted(() => vi.fn());
const resolveWebhookTargetWithAuthOrReject = vi.hoisted(() => vi.fn());
@@ -16,6 +18,8 @@ vi.mock("./auth.js", () => ({
verifyGoogleChatRequest,
}));
type ProcessEventFn = (event: GoogleChatEvent, target: WebhookTarget) => Promise<void>;
function createRequest(authorization?: string): IncomingMessage {
return {
method: "POST",
@@ -66,6 +70,24 @@ function installSimplePipeline(targets: unknown[]) {
);
}
async function runWebhookHandler(options?: {
processEvent?: ProcessEventFn;
authorization?: string;
}) {
const processEvent: ProcessEventFn =
options?.processEvent ?? (vi.fn(async () => {}) as ProcessEventFn);
const { createGoogleChatWebhookRequestHandler } = await import("./monitor-webhook.js");
const handler = createGoogleChatWebhookRequestHandler({
webhookTargets: new Map(),
webhookInFlightLimiter: {} as never,
processEvent,
});
const req = createRequest(options?.authorization);
const res = createResponse();
await expect(handler(req, res)).resolves.toBe(true);
return { processEvent, res };
}
describe("googlechat monitor webhook", () => {
it("accepts add-on payloads that carry systemIdToken in the body", async () => {
installSimplePipeline([
@@ -104,18 +126,7 @@ describe("googlechat monitor webhook", () => {
return null;
});
verifyGoogleChatRequest.mockResolvedValue({ ok: true });
const processEvent = vi.fn(async () => {});
const { createGoogleChatWebhookRequestHandler } = await import("./monitor-webhook.js");
const handler = createGoogleChatWebhookRequestHandler({
webhookTargets: new Map(),
webhookInFlightLimiter: {} as never,
processEvent,
});
const req = createRequest();
const res = createResponse();
await expect(handler(req, res)).resolves.toBe(true);
const { processEvent, res } = await runWebhookHandler();
expect(verifyGoogleChatRequest).toHaveBeenCalledWith(
expect.objectContaining({
@@ -156,18 +167,7 @@ describe("googlechat monitor webhook", () => {
},
},
});
const processEvent = vi.fn(async () => {});
const { createGoogleChatWebhookRequestHandler } = await import("./monitor-webhook.js");
const handler = createGoogleChatWebhookRequestHandler({
webhookTargets: new Map(),
webhookInFlightLimiter: {} as never,
processEvent,
});
const req = createRequest();
const res = createResponse();
await expect(handler(req, res)).resolves.toBe(true);
const { processEvent, res } = await runWebhookHandler();
expect(processEvent).not.toHaveBeenCalled();
expect(res.statusCode).toBe(401);

View File

@@ -378,6 +378,40 @@ describe("createSynologyChatPlugin", () => {
};
}
function makeNamedStartAccountCtx(
accountOverrides: Record<string, unknown>,
abortController = new AbortController(),
) {
return {
abortController,
ctx: {
cfg: {
channels: {
"synology-chat": {
enabled: true,
token: "default-token",
incomingUrl: "https://nas/default",
webhookPath: "/webhook/synology-shared",
dmPolicy: "allowlist",
allowedUserIds: ["123"],
accounts: {
alerts: {
enabled: true,
token: "alerts-token",
incomingUrl: "https://nas/alerts",
...accountOverrides,
},
},
},
},
},
accountId: "alerts",
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
abortSignal: abortController.signal,
},
};
}
async function expectPendingStartAccountPromise(
result: Promise<unknown>,
abortController: AbortController,
@@ -428,31 +462,10 @@ describe("createSynologyChatPlugin", () => {
it("startAccount refuses named accounts without explicit webhookPath in multi-account setups", async () => {
const registerMock = registerPluginHttpRouteMock;
const plugin = createSynologyChatPlugin();
const abortController = new AbortController();
const ctx = {
cfg: {
channels: {
"synology-chat": {
enabled: true,
token: "shared-token",
incomingUrl: "https://nas/incoming",
webhookPath: "/webhook/synology-shared",
accounts: {
alerts: {
enabled: true,
token: "alerts-token",
incomingUrl: "https://nas/alerts",
dmPolicy: "allowlist",
allowedUserIds: ["123"],
},
},
},
},
},
accountId: "alerts",
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
abortSignal: abortController.signal,
};
const { ctx, abortController } = makeNamedStartAccountCtx({
dmPolicy: "allowlist",
allowedUserIds: ["123"],
});
const result = plugin.gateway.startAccount(ctx);
await expectPendingStartAccountPromise(result, abortController);
@@ -465,33 +478,10 @@ describe("createSynologyChatPlugin", () => {
it("startAccount refuses duplicate exact webhook paths across accounts", async () => {
const registerMock = registerPluginHttpRouteMock;
const plugin = createSynologyChatPlugin();
const abortController = new AbortController();
const ctx = {
cfg: {
channels: {
"synology-chat": {
enabled: true,
token: "default-token",
incomingUrl: "https://nas/default",
webhookPath: "/webhook/synology-shared",
dmPolicy: "allowlist",
allowedUserIds: ["123"],
accounts: {
alerts: {
enabled: true,
token: "alerts-token",
incomingUrl: "https://nas/alerts",
webhookPath: "/webhook/synology-shared",
dmPolicy: "open",
},
},
},
},
},
accountId: "alerts",
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
abortSignal: abortController.signal,
};
const { ctx, abortController } = makeNamedStartAccountCtx({
webhookPath: "/webhook/synology-shared",
dmPolicy: "open",
});
const result = plugin.gateway.startAccount(ctx);
await expectPendingStartAccountPromise(result, abortController);

View File

@@ -43,6 +43,39 @@ const validBody = makeFormBody({
text: "Hello bot",
});
async function runDangerousNameMatchReply(
log: { info: any; warn: any; error: any },
options: {
resolvedChatUserId?: number;
accountIdSuffix: string;
},
) {
vi.mocked(resolveLegacyWebhookNameToChatUserId).mockResolvedValueOnce(options.resolvedChatUserId);
const deliver = vi.fn().mockResolvedValue("Bot reply");
const handler = createWebhookHandler({
account: makeAccount({
accountId: `${options.accountIdSuffix}-${Date.now()}`,
dangerouslyAllowNameMatching: true,
}),
deliver,
log,
});
const req = makeReq("POST", validBody);
const res = makeRes();
await handler(req, res);
expect(res._status).toBe(204);
expect(resolveLegacyWebhookNameToChatUserId).toHaveBeenCalledWith({
incomingUrl: "https://nas.example.com/incoming",
mutableWebhookUsername: "testuser",
allowInsecureSsl: true,
log,
});
return { deliver };
}
describe("createWebhookHandler", () => {
let log: { info: any; warn: any; error: any };
@@ -479,27 +512,9 @@ describe("createWebhookHandler", () => {
});
it("only resolves reply recipient by username when break-glass mode is enabled", async () => {
vi.mocked(resolveLegacyWebhookNameToChatUserId).mockResolvedValueOnce(456);
const deliver = vi.fn().mockResolvedValue("Bot reply");
const handler = createWebhookHandler({
account: makeAccount({
accountId: "dangerous-name-match-test-" + Date.now(),
dangerouslyAllowNameMatching: true,
}),
deliver,
log,
});
const req = makeReq("POST", validBody);
const res = makeRes();
await handler(req, res);
expect(res._status).toBe(204);
expect(resolveLegacyWebhookNameToChatUserId).toHaveBeenCalledWith({
incomingUrl: "https://nas.example.com/incoming",
mutableWebhookUsername: "testuser",
allowInsecureSsl: true,
log,
const { deliver } = await runDangerousNameMatchReply(log, {
resolvedChatUserId: 456,
accountIdSuffix: "dangerous-name-match-test",
});
expect(deliver).toHaveBeenCalledWith(
expect.objectContaining({
@@ -516,27 +531,8 @@ describe("createWebhookHandler", () => {
});
it("falls back to payload.user_id when break-glass resolution does not find a match", async () => {
vi.mocked(resolveLegacyWebhookNameToChatUserId).mockResolvedValueOnce(undefined);
const deliver = vi.fn().mockResolvedValue("Bot reply");
const handler = createWebhookHandler({
account: makeAccount({
accountId: "dangerous-name-fallback-test-" + Date.now(),
dangerouslyAllowNameMatching: true,
}),
deliver,
log,
});
const req = makeReq("POST", validBody);
const res = makeRes();
await handler(req, res);
expect(res._status).toBe(204);
expect(resolveLegacyWebhookNameToChatUserId).toHaveBeenCalledWith({
incomingUrl: "https://nas.example.com/incoming",
mutableWebhookUsername: "testuser",
allowInsecureSsl: true,
log,
const { deliver } = await runDangerousNameMatchReply(log, {
accountIdSuffix: "dangerous-name-fallback-test",
});
expect(log.warn).toHaveBeenCalledWith(
'Could not resolve Chat API user_id for "testuser" — falling back to webhook user_id 123. Reply delivery may fail.',