mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-27 01:11:16 +07:00
test: dedupe extension channel fixtures
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.',
|
||||
|
||||
Reference in New Issue
Block a user