fix(line): canonicalize trailing-slash webhook paths (#91649)

* fix(line): canonicalize trailing-slash webhook paths

* fix(clownfish): address review for clawsweeper-commit-openclaw-openclaw-4cf228466770 (1)

---------

Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
This commit is contained in:
openclaw-clownfish[bot]
2026-06-09 16:26:16 +09:00
committed by GitHub
parent f57c3b55fd
commit 994f4f99fe
2 changed files with 42 additions and 2 deletions

View File

@@ -173,9 +173,13 @@ describe("monitorLineProvider lifecycle", () => {
.mockImplementation(() => innerLineWebhookHandlerMock);
unregisterHttpMock.mockReset();
registerWebhookTargetWithPluginRouteMock.mockReset().mockImplementation((params) => {
const key = params.target.path.startsWith("/")
const withLeadingSlash = params.target.path.startsWith("/")
? params.target.path
: `/${params.target.path}`;
const key =
withLeadingSlash.length > 1 && withLeadingSlash.endsWith("/")
? withLeadingSlash.slice(0, -1)
: withLeadingSlash;
const normalizedTarget = { ...params.target, path: key };
const existing = params.targetsByPath.get(key) ?? [];
params.targetsByPath.set(key, [...existing, normalizedTarget]);
@@ -353,6 +357,39 @@ describe("monitorLineProvider lifecycle", () => {
secondMonitor.stop();
});
it("dispatches a signed POST to a configured trailing-slash webhook path", async () => {
const monitor = await monitorLineProvider({
channelAccessToken: "token",
channelSecret: "secret", // pragma: allowlist secret
webhookPath: "/line/webhook/",
accountId: "default",
config: {} as OpenClawConfig,
runtime: {} as RuntimeEnv,
});
const registration = requireWebhookRegistration();
expect(registration.target.path).toBe("/line/webhook");
const route = requireRegisteredRoute();
const payload = JSON.stringify({ events: [{ type: "message" }] });
const signature = crypto.createHmac("SHA256", "secret").update(payload).digest("base64");
const req = Object.assign(createMockIncomingRequest([payload]), {
method: "POST",
headers: { "x-line-signature": signature },
}) as unknown as IncomingMessage;
const res = createRouteResponse();
await route.handler(req, res);
const bot = createLineBotMock.mock.results[0]?.value as {
handleWebhook: ReturnType<typeof vi.fn>;
};
expect(res.statusCode).toBe(200);
expect(bot.handleWebhook).toHaveBeenCalledTimes(1);
monitor.stop();
});
it("acknowledges shared-path POST requests before matched event processing completes", async () => {
const monitor = await monitorLineProvider({
channelAccessToken: "token",

View File

@@ -12,6 +12,7 @@ import {
import {
isRequestBodyLimitError,
normalizePluginHttpPath,
normalizeWebhookPath,
registerWebhookTargetWithPluginRoute,
requestBodyErrorToText,
resolveSingleWebhookTarget,
@@ -337,7 +338,9 @@ export async function monitorLineProvider(
},
});
const normalizedPath = normalizePluginHttpPath(webhookPath, "/line/webhook") ?? "/line/webhook";
const normalizedPath = normalizeWebhookPath(
normalizePluginHttpPath(webhookPath, "/line/webhook") ?? "/line/webhook",
);
const createScopedLineWebhookHandler = (target: LineWebhookTarget) =>
createLineNodeWebhookHandler({
channelSecret: target.channelSecret,