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", 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,

View File

@@ -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 () => {

View File

@@ -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,
}), }),

View File

@@ -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);

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( 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);

View File

@@ -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.',