

























@@ -1,15 +1,18 @@
1+import crypto from "node:crypto";
2+import { EventEmitter } from "node:events";
13import type { IncomingMessage, ServerResponse } from "node:http";
24import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
35import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
46import { WEBHOOK_IN_FLIGHT_DEFAULTS } from "openclaw/plugin-sdk/webhook-request-guards";
57import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
8+import { createMockIncomingRequest } from "openclaw/plugin-sdk/test-env";
69710type LineNodeWebhookHandler = (req: IncomingMessage, res: ServerResponse) => Promise<void>;
811912const {
1013 createLineBotMock,
1114 createLineNodeWebhookHandlerMock,
12-registerPluginHttpRouteMock,
15+registerWebhookTargetWithPluginRouteMock,
1316 unregisterHttpMock,
1417} = vi.hoisted(() => ({
1518createLineBotMock: vi.fn(() => ({
@@ -19,7 +22,7 @@ const {
1922createLineNodeWebhookHandlerMock: vi.fn<() => LineNodeWebhookHandler>(() =>
2023vi.fn<LineNodeWebhookHandler>(async () => {}),
2124),
22-registerPluginHttpRouteMock: vi.fn(),
25+registerWebhookTargetWithPluginRouteMock: vi.fn(),
2326unregisterHttpMock: vi.fn(),
2427}));
2528@@ -53,14 +56,24 @@ vi.mock("openclaw/plugin-sdk/channel-reply-pipeline", () => ({
5356createChannelReplyPipeline: vi.fn(() => ({})),
5457}));
555856-vi.mock("openclaw/plugin-sdk/webhook-ingress", () => ({
57-normalizePluginHttpPath: (_path: string | undefined, fallback: string) => fallback,
58-registerPluginHttpRoute: registerPluginHttpRouteMock,
59-}));
59+vi.mock("openclaw/plugin-sdk/webhook-ingress", async () => {
60+const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/webhook-ingress")>(
61+"openclaw/plugin-sdk/webhook-ingress",
62+);
63+return {
64+ ...actual,
65+normalizePluginHttpPath: (path: string | undefined, fallback: string) => path ?? fallback,
66+registerWebhookTargetWithPluginRoute: registerWebhookTargetWithPluginRouteMock,
67+};
68+});
606961-vi.mock("./webhook-node.js", () => ({
62-createLineNodeWebhookHandler: createLineNodeWebhookHandlerMock,
63-}));
70+vi.mock("./webhook-node.js", async () => {
71+const actual = await vi.importActual<typeof import("./webhook-node.js")>("./webhook-node.js");
72+return {
73+ ...actual,
74+createLineNodeWebhookHandler: createLineNodeWebhookHandlerMock,
75+};
76+});
64776578vi.mock("./auto-reply-delivery.js", () => ({
6679deliverLineAutoReply: vi.fn(),
@@ -101,16 +114,37 @@ describe("monitorLineProvider lifecycle", () => {
101114beforeEach(() => {
102115clearLineRuntimeStateForTests();
103116createLineBotMock.mockReset();
104-createLineBotMock.mockReturnValue({
117+createLineBotMock.mockImplementation(() => ({
105118account: { accountId: "default" },
106119handleWebhook: vi.fn(),
107-});
120+}));
108121innerLineWebhookHandlerMock = vi.fn<LineNodeWebhookHandler>(async () => {});
109122createLineNodeWebhookHandlerMock
110123.mockReset()
111124.mockImplementation(() => innerLineWebhookHandlerMock);
112125unregisterHttpMock.mockReset();
113-registerPluginHttpRouteMock.mockReset().mockReturnValue(unregisterHttpMock);
126+registerWebhookTargetWithPluginRouteMock.mockReset().mockImplementation((params) => {
127+const key = params.target.path.startsWith("/")
128+ ? params.target.path
129+ : `/${params.target.path}`;
130+const normalizedTarget = { ...params.target, path: key };
131+const existing = params.targetsByPath.get(key) ?? [];
132+params.targetsByPath.set(key, [...existing, normalizedTarget]);
133+return {
134+target: normalizedTarget,
135+unregister: () => {
136+unregisterHttpMock();
137+const updated = (params.targetsByPath.get(key) ?? []).filter(
138+(entry: unknown) => entry !== normalizedTarget,
139+);
140+if (updated.length > 0) {
141+params.targetsByPath.set(key, updated);
142+} else {
143+params.targetsByPath.delete(key);
144+}
145+},
146+};
147+});
114148});
115149116150const createRouteResponse = () => {
@@ -140,9 +174,9 @@ describe("monitorLineProvider lifecycle", () => {
140174return monitor;
141175});
142176143-expect(registerPluginHttpRouteMock).toHaveBeenCalledTimes(1);
144-expect(registerPluginHttpRouteMock).toHaveBeenCalledWith(
145-expect.objectContaining({ auth: "plugin" }),
177+expect(registerWebhookTargetWithPluginRouteMock).toHaveBeenCalledTimes(1);
178+expect(registerWebhookTargetWithPluginRouteMock).toHaveBeenCalledWith(
179+expect.objectContaining({ route: expect.objectContaining({ auth: "plugin" }) }),
146180);
147181expect(resolved).toBe(false);
148182@@ -151,6 +185,31 @@ describe("monitorLineProvider lifecycle", () => {
151185expect(unregisterHttpMock).toHaveBeenCalledTimes(1);
152186});
153187188+it("registers an account target without replacing existing route ownership", async () => {
189+const monitor = await monitorLineProvider({
190+channelAccessToken: "token",
191+channelSecret: "secret", // pragma: allowlist secret
192+accountId: "work",
193+config: {} as OpenClawConfig,
194+runtime: {} as RuntimeEnv,
195+});
196+197+const registration = registerWebhookTargetWithPluginRouteMock.mock.calls[0]?.[0];
198+expect(registration).toEqual(
199+expect.objectContaining({
200+target: expect.objectContaining({ accountId: "work", path: "/line/webhook" }),
201+route: expect.objectContaining({
202+accountId: "work",
203+auth: "plugin",
204+pluginId: "line",
205+}),
206+}),
207+);
208+expect(registration?.route).not.toHaveProperty("path");
209+expect(registration?.route).not.toHaveProperty("replaceExisting");
210+monitor.stop();
211+});
212+154213it("stops immediately when signal is already aborted", async () => {
155214const abort = new AbortController();
156215abort.abort();
@@ -210,26 +269,100 @@ describe("monitorLineProvider lifecycle", () => {
210269monitor.stop();
211270});
212271213-it("rejects webhook requests above the shared in-flight limit before body handling", async () => {
214-const limit = WEBHOOK_IN_FLIGHT_DEFAULTS.maxInFlightPerKey;
215-const releaseRequests: Array<() => void> = [];
216-let reachLimit!: () => void;
217-const reachedLimit = new Promise<void>((resolve) => {
218-reachLimit = resolve;
272+it("dispatches shared-path webhook posts to the account matching the signature", async () => {
273+const firstMonitor = await monitorLineProvider({
274+channelAccessToken: "first-token",
275+channelSecret: "first-secret", // pragma: allowlist secret
276+accountId: "first",
277+config: {} as OpenClawConfig,
278+runtime: {} as RuntimeEnv,
279+});
280+const secondMonitor = await monitorLineProvider({
281+channelAccessToken: "second-token",
282+channelSecret: "second-secret", // pragma: allowlist secret
283+accountId: "second",
284+config: {} as OpenClawConfig,
285+runtime: {} as RuntimeEnv,
219286});
220287221-innerLineWebhookHandlerMock.mockImplementation(
222-async (_req: IncomingMessage, res: ServerResponse) => {
223-if (releaseRequests.length === limit - 1) {
224-reachLimit();
225-}
226-await new Promise<void>((resolve) => {
227-releaseRequests.push(resolve);
228-});
229-res.statusCode = 200;
230-res.end();
231-},
232-);
288+const route = registerWebhookTargetWithPluginRouteMock.mock.calls[0]?.[0]?.route as
289+| { handler: (req: IncomingMessage, res: ServerResponse) => Promise<void> }
290+| undefined;
291+expect(route).toBeDefined();
292+293+const payload = JSON.stringify({ events: [{ type: "message" }] });
294+const signature = crypto.createHmac("SHA256", "second-secret").update(payload).digest("base64");
295+const req = Object.assign(createMockIncomingRequest([payload]), {
296+method: "POST",
297+headers: { "x-line-signature": signature },
298+}) as unknown as IncomingMessage;
299+const res = createRouteResponse();
300+301+await route!.handler(req, res);
302+303+const firstBot = createLineBotMock.mock.results[0]?.value as {
304+handleWebhook: ReturnType<typeof vi.fn>;
305+};
306+const secondBot = createLineBotMock.mock.results[1]?.value as {
307+handleWebhook: ReturnType<typeof vi.fn>;
308+};
309+expect(res.statusCode).toBe(200);
310+expect(firstBot.handleWebhook).not.toHaveBeenCalled();
311+expect(secondBot.handleWebhook).toHaveBeenCalledTimes(1);
312+313+firstMonitor.stop();
314+secondMonitor.stop();
315+});
316+317+it("rejects ambiguous shared-path webhook signatures", async () => {
318+const firstMonitor = await monitorLineProvider({
319+channelAccessToken: "first-token",
320+channelSecret: "shared-secret", // pragma: allowlist secret
321+accountId: "first",
322+config: {} as OpenClawConfig,
323+runtime: {} as RuntimeEnv,
324+});
325+const secondMonitor = await monitorLineProvider({
326+channelAccessToken: "second-token",
327+channelSecret: "shared-secret", // pragma: allowlist secret
328+accountId: "second",
329+config: {} as OpenClawConfig,
330+runtime: {} as RuntimeEnv,
331+});
332+333+const route = registerWebhookTargetWithPluginRouteMock.mock.calls[0]?.[0]?.route as
334+| { handler: (req: IncomingMessage, res: ServerResponse) => Promise<void> }
335+| undefined;
336+expect(route).toBeDefined();
337+338+const payload = JSON.stringify({ events: [{ type: "message" }] });
339+const signature = crypto.createHmac("SHA256", "shared-secret").update(payload).digest("base64");
340+const req = Object.assign(createMockIncomingRequest([payload]), {
341+method: "POST",
342+headers: { "x-line-signature": signature },
343+}) as unknown as IncomingMessage;
344+const res = createRouteResponse();
345+346+await route!.handler(req, res);
347+348+const firstBot = createLineBotMock.mock.results[0]?.value as {
349+handleWebhook: ReturnType<typeof vi.fn>;
350+};
351+const secondBot = createLineBotMock.mock.results[1]?.value as {
352+handleWebhook: ReturnType<typeof vi.fn>;
353+};
354+expect(res.statusCode).toBe(401);
355+expect(res.end).toHaveBeenCalledWith(JSON.stringify({ error: "Ambiguous webhook target" }));
356+expect(firstBot.handleWebhook).not.toHaveBeenCalled();
357+expect(secondBot.handleWebhook).not.toHaveBeenCalled();
358+359+firstMonitor.stop();
360+secondMonitor.stop();
361+});
362+363+it("rejects webhook requests above the shared in-flight limit before body handling", async () => {
364+const limit = WEBHOOK_IN_FLIGHT_DEFAULTS.maxInFlightPerKey;
365+const heldRequests: Array<EventEmitter & { destroy: () => void }> = [];
233366234367const monitor = await monitorLineProvider({
235368channelAccessToken: "token",
@@ -238,30 +371,51 @@ describe("monitorLineProvider lifecycle", () => {
238371runtime: {} as RuntimeEnv,
239372});
240373241-const route = registerPluginHttpRouteMock.mock.calls[0]?.[0] as
374+const route = registerWebhookTargetWithPluginRouteMock.mock.calls[0]?.[0]?.route as
242375| { handler: (req: IncomingMessage, res: ServerResponse) => Promise<void> }
243376| undefined;
244377expect(route).toBeDefined();
245-const createPostRequest = () =>
246-({
378+const createHeldPostRequest = () => {
379+const req = Object.assign(new EventEmitter(), {
380+destroyed: false,
381+destroy(this: EventEmitter & { destroyed: boolean }) {
382+this.destroyed = true;
383+this.emit("close");
384+},
385+});
386+heldRequests.push(req);
387+return Object.assign(req, {
247388method: "POST",
248-headers: {},
249-}) as IncomingMessage;
389+headers: { "x-line-signature": "pending" },
390+}) as unknown as IncomingMessage;
391+};
392+const createSignedPostRequest = () => {
393+const payload = JSON.stringify({ events: [{ type: "message" }] });
394+const signature = crypto.createHmac("SHA256", "secret").update(payload).digest("base64");
395+const req = createMockIncomingRequest([payload]);
396+return Object.assign(req, {
397+method: "POST",
398+headers: { "x-line-signature": signature },
399+}) as unknown as IncomingMessage;
400+};
250401251402const firstRequests = Array.from({ length: limit }, () =>
252-route!.handler(createPostRequest(), createRouteResponse()),
403+route!.handler(createHeldPostRequest(), createRouteResponse()),
253404);
254-await reachedLimit;
405+await new Promise((resolve) => setImmediate(resolve));
255406256407const overflowResponse = createRouteResponse();
257-await route!.handler(createPostRequest(), overflowResponse);
408+await route!.handler(createSignedPostRequest(), overflowResponse);
258409259-expect(innerLineWebhookHandlerMock).toHaveBeenCalledTimes(limit);
410+const bot = createLineBotMock.mock.results[0]?.value as {
411+handleWebhook: ReturnType<typeof vi.fn>;
412+};
413+expect(bot.handleWebhook).not.toHaveBeenCalled();
260414expect(overflowResponse.statusCode).toBe(429);
261415expect(overflowResponse.end).toHaveBeenCalledWith("Too Many Requests");
262416263-releaseRequests.splice(0).forEach((release) => release());
264-await Promise.all(firstRequests);
417+heldRequests.splice(0).forEach((req) => req.destroy());
418+await Promise.allSettled(firstRequests);
265419monitor.stop();
266420});
267421});
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。