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",
|
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(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mockResolveAgentRoute.mockReturnValue({
|
mockResolveAgentRoute.mockReturnValue({
|
||||||
@@ -112,33 +157,12 @@ describe("broadcast dispatch", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("dispatches to all broadcast agents when bot is mentioned", async () => {
|
it("dispatches to all broadcast agents when bot is mentioned", async () => {
|
||||||
const cfg: ClawdbotConfig = {
|
const cfg = createBroadcastConfig();
|
||||||
broadcast: { "oc-broadcast-group": ["susan", "main"] },
|
const event = createBroadcastEvent({
|
||||||
agents: { list: [{ id: "main" }, { id: "susan" }] },
|
messageId: "msg-broadcast-mentioned",
|
||||||
channels: {
|
text: "hello @bot",
|
||||||
feishu: {
|
botMentioned: true,
|
||||||
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: "" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
await handleFeishuMessage({
|
await handleFeishuMessage({
|
||||||
cfg,
|
cfg,
|
||||||
@@ -160,30 +184,11 @@ describe("broadcast dispatch", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("skips broadcast dispatch when bot is NOT mentioned (requireMention=true)", async () => {
|
it("skips broadcast dispatch when bot is NOT mentioned (requireMention=true)", async () => {
|
||||||
const cfg: ClawdbotConfig = {
|
const cfg = createBroadcastConfig();
|
||||||
broadcast: { "oc-broadcast-group": ["susan", "main"] },
|
const event = createBroadcastEvent({
|
||||||
agents: { list: [{ id: "main" }, { id: "susan" }] },
|
messageId: "msg-broadcast-not-mentioned",
|
||||||
channels: {
|
text: "hello everyone",
|
||||||
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" }),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
await handleFeishuMessage({
|
await handleFeishuMessage({
|
||||||
cfg,
|
cfg,
|
||||||
@@ -197,30 +202,11 @@ describe("broadcast dispatch", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("skips broadcast dispatch when bot identity is unknown (requireMention=true)", async () => {
|
it("skips broadcast dispatch when bot identity is unknown (requireMention=true)", async () => {
|
||||||
const cfg: ClawdbotConfig = {
|
const cfg = createBroadcastConfig();
|
||||||
broadcast: { "oc-broadcast-group": ["susan", "main"] },
|
const event = createBroadcastEvent({
|
||||||
agents: { list: [{ id: "main" }, { id: "susan" }] },
|
messageId: "msg-broadcast-unknown-bot-id",
|
||||||
channels: {
|
text: "hello everyone",
|
||||||
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" }),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
await handleFeishuMessage({
|
await handleFeishuMessage({
|
||||||
cfg,
|
cfg,
|
||||||
|
|||||||
@@ -61,6 +61,41 @@ function getDescribedActions(cfg: OpenClawConfig): string[] {
|
|||||||
return [...(feishuPlugin.actions?.describeMessageTool?.({ cfg })?.actions ?? [])];
|
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", () => {
|
describe("feishuPlugin.status.probeAccount", () => {
|
||||||
it("uses current account credentials for multi-account config", async () => {
|
it("uses current account credentials for multi-account config", async () => {
|
||||||
const cfg = {
|
const cfg = {
|
||||||
@@ -248,69 +283,17 @@ describe("feishuPlugin actions", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("rejects raw legacy card command payloads", async () => {
|
it("rejects raw legacy card command payloads", async () => {
|
||||||
const legacyCard = {
|
await expectLegacyFeishuCardPayloadRejected(
|
||||||
schema: "2.0",
|
cfg,
|
||||||
body: {
|
createLegacyFeishuButtonCard({ command: "/new" }),
|
||||||
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.",
|
|
||||||
);
|
);
|
||||||
expect(sendCardFeishuMock).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects raw legacy card text payloads", async () => {
|
it("rejects raw legacy card text payloads", async () => {
|
||||||
const legacyCard = {
|
await expectLegacyFeishuCardPayloadRejected(
|
||||||
schema: "2.0",
|
cfg,
|
||||||
body: {
|
createLegacyFeishuButtonCard({ text: "/new" }),
|
||||||
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.",
|
|
||||||
);
|
);
|
||||||
expect(sendCardFeishuMock).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows non-button controls to carry text metadata values", async () => {
|
it("allows non-button controls to carry text metadata values", async () => {
|
||||||
|
|||||||
@@ -58,6 +58,61 @@ function primeCommonDefaults() {
|
|||||||
warnMissingProviderGroupPolicyFallbackOnce.mockReturnValue(undefined);
|
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", () => {
|
describe("googlechat inbound access policy", () => {
|
||||||
it("issues a pairing challenge for unauthorized DMs in pairing mode", async () => {
|
it("issues a pairing challenge for unauthorized DMs in pairing mode", async () => {
|
||||||
primeCommonDefaults();
|
primeCommonDefaults();
|
||||||
@@ -120,27 +175,13 @@ describe("googlechat inbound access policy", () => {
|
|||||||
|
|
||||||
it("allows group traffic when sender and mention gates pass", async () => {
|
it("allows group traffic when sender and mention gates pass", async () => {
|
||||||
primeCommonDefaults();
|
primeCommonDefaults();
|
||||||
createChannelPairingController.mockReturnValue({
|
allowInboundGroupTraffic();
|
||||||
readAllowFromStore: vi.fn(async () => []),
|
|
||||||
issueChallenge: vi.fn(),
|
|
||||||
});
|
|
||||||
resolveDmGroupAccessWithLists.mockReturnValue({
|
|
||||||
decision: "allow",
|
|
||||||
effectiveAllowFrom: [],
|
|
||||||
effectiveGroupAllowFrom: ["users/alice"],
|
|
||||||
});
|
|
||||||
resolveMentionGatingWithBypass.mockReturnValue({
|
|
||||||
shouldSkip: false,
|
|
||||||
effectiveWasMentioned: true,
|
|
||||||
});
|
|
||||||
const core = createCore();
|
const core = createCore();
|
||||||
core.channel.commands.shouldComputeCommandAuthorized.mockReturnValue(true);
|
core.channel.commands.shouldComputeCommandAuthorized.mockReturnValue(true);
|
||||||
core.channel.commands.resolveCommandAuthorizedFromAuthorizers.mockReturnValue(true);
|
core.channel.commands.resolveCommandAuthorizedFromAuthorizers.mockReturnValue(true);
|
||||||
|
|
||||||
const { applyGoogleChatInboundAccessPolicy } = await import("./monitor-access.js");
|
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
applyGoogleChatInboundAccessPolicy({
|
applyInboundAccessPolicy({
|
||||||
account: {
|
account: {
|
||||||
accountId: "default",
|
accountId: "default",
|
||||||
config: {
|
config: {
|
||||||
@@ -154,12 +195,7 @@ describe("googlechat inbound access policy", () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as never,
|
} as never,
|
||||||
config: {
|
|
||||||
channels: { googlechat: {} },
|
|
||||||
commands: { useAccessGroups: true },
|
|
||||||
} as never,
|
|
||||||
core: core as never,
|
core: core as never,
|
||||||
space: { name: "spaces/AAA", displayName: "Team Room" } as never,
|
|
||||||
message: {
|
message: {
|
||||||
annotations: [
|
annotations: [
|
||||||
{
|
{
|
||||||
@@ -168,12 +204,6 @@ describe("googlechat inbound access policy", () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
} as never,
|
} as never,
|
||||||
isGroup: true,
|
|
||||||
senderId: "users/alice",
|
|
||||||
senderName: "Alice",
|
|
||||||
senderEmail: "alice@example.com",
|
|
||||||
rawBody: "hello team",
|
|
||||||
logVerbose: vi.fn(),
|
|
||||||
}),
|
}),
|
||||||
).resolves.toEqual({
|
).resolves.toEqual({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -185,17 +215,8 @@ describe("googlechat inbound access policy", () => {
|
|||||||
|
|
||||||
it("drops unauthorized group control commands", async () => {
|
it("drops unauthorized group control commands", async () => {
|
||||||
primeCommonDefaults();
|
primeCommonDefaults();
|
||||||
createChannelPairingController.mockReturnValue({
|
allowInboundGroupTraffic({
|
||||||
readAllowFromStore: vi.fn(async () => []),
|
|
||||||
issueChallenge: vi.fn(),
|
|
||||||
});
|
|
||||||
resolveDmGroupAccessWithLists.mockReturnValue({
|
|
||||||
decision: "allow",
|
|
||||||
effectiveAllowFrom: [],
|
|
||||||
effectiveGroupAllowFrom: [],
|
effectiveGroupAllowFrom: [],
|
||||||
});
|
|
||||||
resolveMentionGatingWithBypass.mockReturnValue({
|
|
||||||
shouldSkip: false,
|
|
||||||
effectiveWasMentioned: false,
|
effectiveWasMentioned: false,
|
||||||
});
|
});
|
||||||
const core = createCore();
|
const core = createCore();
|
||||||
@@ -204,25 +225,9 @@ describe("googlechat inbound access policy", () => {
|
|||||||
core.channel.commands.isControlCommandMessage.mockReturnValue(true);
|
core.channel.commands.isControlCommandMessage.mockReturnValue(true);
|
||||||
const logVerbose = vi.fn();
|
const logVerbose = vi.fn();
|
||||||
|
|
||||||
const { applyGoogleChatInboundAccessPolicy } = await import("./monitor-access.js");
|
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
applyGoogleChatInboundAccessPolicy({
|
applyInboundAccessPolicy({
|
||||||
account: {
|
|
||||||
accountId: "default",
|
|
||||||
config: {},
|
|
||||||
} as never,
|
|
||||||
config: {
|
|
||||||
channels: { googlechat: {} },
|
|
||||||
commands: { useAccessGroups: true },
|
|
||||||
} as never,
|
|
||||||
core: core as never,
|
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",
|
rawBody: "/admin",
|
||||||
logVerbose,
|
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 () => {
|
it("does not match group policy by mutable space displayName when the stable id differs", async () => {
|
||||||
primeCommonDefaults();
|
primeCommonDefaults();
|
||||||
createChannelPairingController.mockReturnValue({
|
allowInboundGroupTraffic();
|
||||||
readAllowFromStore: vi.fn(async () => []),
|
|
||||||
issueChallenge: vi.fn(),
|
|
||||||
});
|
|
||||||
resolveDmGroupAccessWithLists.mockReturnValue({
|
|
||||||
decision: "allow",
|
|
||||||
effectiveAllowFrom: [],
|
|
||||||
effectiveGroupAllowFrom: ["users/alice"],
|
|
||||||
});
|
|
||||||
resolveMentionGatingWithBypass.mockReturnValue({
|
|
||||||
shouldSkip: false,
|
|
||||||
effectiveWasMentioned: true,
|
|
||||||
});
|
|
||||||
const logVerbose = vi.fn();
|
const logVerbose = vi.fn();
|
||||||
|
|
||||||
const { applyGoogleChatInboundAccessPolicy } = await import("./monitor-access.js");
|
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
applyGoogleChatInboundAccessPolicy({
|
applyInboundAccessPolicy({
|
||||||
account: {
|
account: {
|
||||||
accountId: "default",
|
accountId: "default",
|
||||||
config: {
|
config: {
|
||||||
@@ -264,10 +255,6 @@ describe("googlechat inbound access policy", () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as never,
|
} as never,
|
||||||
config: {
|
|
||||||
channels: { googlechat: {} },
|
|
||||||
commands: { useAccessGroups: true },
|
|
||||||
} as never,
|
|
||||||
core: createCore() as never,
|
core: createCore() as never,
|
||||||
space: { name: "spaces/BBB", displayName: "Finance Ops" } as never,
|
space: { name: "spaces/BBB", displayName: "Finance Ops" } as never,
|
||||||
message: {
|
message: {
|
||||||
@@ -278,10 +265,6 @@ describe("googlechat inbound access policy", () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
} as never,
|
} as never,
|
||||||
isGroup: true,
|
|
||||||
senderId: "users/alice",
|
|
||||||
senderName: "Alice",
|
|
||||||
senderEmail: "alice@example.com",
|
|
||||||
rawBody: "show quarter close status",
|
rawBody: "show quarter close status",
|
||||||
logVerbose,
|
logVerbose,
|
||||||
}),
|
}),
|
||||||
@@ -301,25 +284,11 @@ describe("googlechat inbound access policy", () => {
|
|||||||
groupPolicy: "open",
|
groupPolicy: "open",
|
||||||
providerMissingFallbackApplied: false,
|
providerMissingFallbackApplied: false,
|
||||||
});
|
});
|
||||||
createChannelPairingController.mockReturnValue({
|
allowInboundGroupTraffic();
|
||||||
readAllowFromStore: vi.fn(async () => []),
|
|
||||||
issueChallenge: vi.fn(),
|
|
||||||
});
|
|
||||||
resolveDmGroupAccessWithLists.mockReturnValue({
|
|
||||||
decision: "allow",
|
|
||||||
effectiveAllowFrom: [],
|
|
||||||
effectiveGroupAllowFrom: ["users/alice"],
|
|
||||||
});
|
|
||||||
resolveMentionGatingWithBypass.mockReturnValue({
|
|
||||||
shouldSkip: false,
|
|
||||||
effectiveWasMentioned: true,
|
|
||||||
});
|
|
||||||
const logVerbose = vi.fn();
|
const logVerbose = vi.fn();
|
||||||
|
|
||||||
const { applyGoogleChatInboundAccessPolicy } = await import("./monitor-access.js");
|
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
applyGoogleChatInboundAccessPolicy({
|
applyInboundAccessPolicy({
|
||||||
account: {
|
account: {
|
||||||
accountId: "default",
|
accountId: "default",
|
||||||
config: {
|
config: {
|
||||||
@@ -335,17 +304,8 @@ describe("googlechat inbound access policy", () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as never,
|
} as never,
|
||||||
config: {
|
|
||||||
channels: { googlechat: {} },
|
|
||||||
commands: { useAccessGroups: true },
|
|
||||||
} as never,
|
|
||||||
core: createCore() as never,
|
core: createCore() as never,
|
||||||
space: { name: "spaces/BBB", displayName: "Finance Ops" } 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",
|
rawBody: "show quarter close status",
|
||||||
logVerbose,
|
logVerbose,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
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 readJsonWebhookBodyOrReject = vi.hoisted(() => vi.fn());
|
||||||
const resolveWebhookTargetWithAuthOrReject = vi.hoisted(() => vi.fn());
|
const resolveWebhookTargetWithAuthOrReject = vi.hoisted(() => vi.fn());
|
||||||
@@ -16,6 +18,8 @@ vi.mock("./auth.js", () => ({
|
|||||||
verifyGoogleChatRequest,
|
verifyGoogleChatRequest,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
type ProcessEventFn = (event: GoogleChatEvent, target: WebhookTarget) => Promise<void>;
|
||||||
|
|
||||||
function createRequest(authorization?: string): IncomingMessage {
|
function createRequest(authorization?: string): IncomingMessage {
|
||||||
return {
|
return {
|
||||||
method: "POST",
|
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", () => {
|
describe("googlechat monitor webhook", () => {
|
||||||
it("accepts add-on payloads that carry systemIdToken in the body", async () => {
|
it("accepts add-on payloads that carry systemIdToken in the body", async () => {
|
||||||
installSimplePipeline([
|
installSimplePipeline([
|
||||||
@@ -104,18 +126,7 @@ describe("googlechat monitor webhook", () => {
|
|||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
verifyGoogleChatRequest.mockResolvedValue({ ok: true });
|
verifyGoogleChatRequest.mockResolvedValue({ ok: true });
|
||||||
const processEvent = vi.fn(async () => {});
|
const { processEvent, res } = await runWebhookHandler();
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
expect(verifyGoogleChatRequest).toHaveBeenCalledWith(
|
expect(verifyGoogleChatRequest).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@@ -156,18 +167,7 @@ describe("googlechat monitor webhook", () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const processEvent = vi.fn(async () => {});
|
const { processEvent, res } = await runWebhookHandler();
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
expect(processEvent).not.toHaveBeenCalled();
|
expect(processEvent).not.toHaveBeenCalled();
|
||||||
expect(res.statusCode).toBe(401);
|
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(
|
async function expectPendingStartAccountPromise(
|
||||||
result: Promise<unknown>,
|
result: Promise<unknown>,
|
||||||
abortController: AbortController,
|
abortController: AbortController,
|
||||||
@@ -428,31 +462,10 @@ describe("createSynologyChatPlugin", () => {
|
|||||||
it("startAccount refuses named accounts without explicit webhookPath in multi-account setups", async () => {
|
it("startAccount refuses named accounts without explicit webhookPath in multi-account setups", async () => {
|
||||||
const registerMock = registerPluginHttpRouteMock;
|
const registerMock = registerPluginHttpRouteMock;
|
||||||
const plugin = createSynologyChatPlugin();
|
const plugin = createSynologyChatPlugin();
|
||||||
const abortController = new AbortController();
|
const { ctx, abortController } = makeNamedStartAccountCtx({
|
||||||
const ctx = {
|
dmPolicy: "allowlist",
|
||||||
cfg: {
|
allowedUserIds: ["123"],
|
||||||
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 result = plugin.gateway.startAccount(ctx);
|
const result = plugin.gateway.startAccount(ctx);
|
||||||
await expectPendingStartAccountPromise(result, abortController);
|
await expectPendingStartAccountPromise(result, abortController);
|
||||||
@@ -465,33 +478,10 @@ describe("createSynologyChatPlugin", () => {
|
|||||||
it("startAccount refuses duplicate exact webhook paths across accounts", async () => {
|
it("startAccount refuses duplicate exact webhook paths across accounts", async () => {
|
||||||
const registerMock = registerPluginHttpRouteMock;
|
const registerMock = registerPluginHttpRouteMock;
|
||||||
const plugin = createSynologyChatPlugin();
|
const plugin = createSynologyChatPlugin();
|
||||||
const abortController = new AbortController();
|
const { ctx, abortController } = makeNamedStartAccountCtx({
|
||||||
const ctx = {
|
webhookPath: "/webhook/synology-shared",
|
||||||
cfg: {
|
dmPolicy: "open",
|
||||||
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 result = plugin.gateway.startAccount(ctx);
|
const result = plugin.gateway.startAccount(ctx);
|
||||||
await expectPendingStartAccountPromise(result, abortController);
|
await expectPendingStartAccountPromise(result, abortController);
|
||||||
|
|||||||
@@ -43,6 +43,39 @@ const validBody = makeFormBody({
|
|||||||
text: "Hello bot",
|
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", () => {
|
describe("createWebhookHandler", () => {
|
||||||
let log: { info: any; warn: any; error: any };
|
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 () => {
|
it("only resolves reply recipient by username when break-glass mode is enabled", async () => {
|
||||||
vi.mocked(resolveLegacyWebhookNameToChatUserId).mockResolvedValueOnce(456);
|
const { deliver } = await runDangerousNameMatchReply(log, {
|
||||||
const deliver = vi.fn().mockResolvedValue("Bot reply");
|
resolvedChatUserId: 456,
|
||||||
const handler = createWebhookHandler({
|
accountIdSuffix: "dangerous-name-match-test",
|
||||||
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,
|
|
||||||
});
|
});
|
||||||
expect(deliver).toHaveBeenCalledWith(
|
expect(deliver).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
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 () => {
|
it("falls back to payload.user_id when break-glass resolution does not find a match", async () => {
|
||||||
vi.mocked(resolveLegacyWebhookNameToChatUserId).mockResolvedValueOnce(undefined);
|
const { deliver } = await runDangerousNameMatchReply(log, {
|
||||||
const deliver = vi.fn().mockResolvedValue("Bot reply");
|
accountIdSuffix: "dangerous-name-fallback-test",
|
||||||
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,
|
|
||||||
});
|
});
|
||||||
expect(log.warn).toHaveBeenCalledWith(
|
expect(log.warn).toHaveBeenCalledWith(
|
||||||
'Could not resolve Chat API user_id for "testuser" — falling back to webhook user_id 123. Reply delivery may fail.',
|
'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