mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 06:11:37 +00:00
fix(line): preserve webhook object error details (#113606)
* fix(line): preserve webhook object error details * fix(line): preserve structured errors on the live webhook route * test(line): provide the complete typed runtime to webhook proof --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
// Line tests cover monitor.lifecycle plugin behavior.
|
||||
import crypto from "node:crypto";
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { createMockIncomingRequest } from "openclaw/plugin-sdk/test-env";
|
||||
@@ -378,6 +378,102 @@ describe("monitorLineProvider lifecycle", () => {
|
||||
await secondMonitor.stop();
|
||||
});
|
||||
|
||||
it("redacts structured admission failures from signed HTTP webhook requests", async () => {
|
||||
const runtimeError = vi.fn<(...args: unknown[]) => void>();
|
||||
const runtime: RuntimeEnv = {
|
||||
log: vi.fn<(...args: unknown[]) => void>(),
|
||||
error: runtimeError,
|
||||
exit: vi.fn<(code: number) => void>(),
|
||||
};
|
||||
const monitor = await monitorLineProvider({
|
||||
channelAccessToken: "token",
|
||||
channelSecret: "secret", // pragma: allowlist secret
|
||||
accountId: "default",
|
||||
config: {} as OpenClawConfig,
|
||||
runtime,
|
||||
});
|
||||
const route = requireRegisteredRoute();
|
||||
const server = createServer((req, res) => {
|
||||
void route.handler(req, res);
|
||||
});
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
server.removeListener("error", reject);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("expected LINE webhook test server to have a TCP address");
|
||||
}
|
||||
|
||||
const webhookUrl = `http://127.0.0.1:${address.port}/line/webhook`;
|
||||
const payload = JSON.stringify({ events: [{ type: "message" }] });
|
||||
const rejected = await fetch(webhookUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-line-signature": "invalid-signature",
|
||||
},
|
||||
body: payload,
|
||||
});
|
||||
expect(rejected.status).toBe(401);
|
||||
expect(await rejected.json()).toEqual({ error: "Invalid signature" });
|
||||
|
||||
const bot = createLineBotMock.mock.results[0]?.value;
|
||||
if (!bot) {
|
||||
throw new Error("expected registered LINE webhook bot");
|
||||
}
|
||||
expect(bot.handleWebhook).not.toHaveBeenCalled();
|
||||
|
||||
const bearerToken = "test_line_access_token_1234567890";
|
||||
bot.handleWebhook.mockRejectedValueOnce({
|
||||
code: "LINE_ADMISSION_REJECTED",
|
||||
retryAfterMs: 250,
|
||||
authorization: `Bearer ${bearerToken}`,
|
||||
});
|
||||
const signature = crypto.createHmac("SHA256", "secret").update(payload).digest("base64");
|
||||
const response = await fetch(webhookUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-line-signature": signature,
|
||||
},
|
||||
body: payload,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(await response.json()).toEqual({ error: "Internal server error" });
|
||||
expect(bot.handleWebhook).toHaveBeenCalledTimes(1);
|
||||
expect(runtimeError).toHaveBeenCalledTimes(1);
|
||||
const message = String(runtimeError.mock.calls[0]?.[0]);
|
||||
expect(message).toContain("line webhook error:");
|
||||
expect(message).toContain("LINE_ADMISSION_REJECTED");
|
||||
expect(message).toContain("retryAfterMs");
|
||||
expect(message).not.toContain("[object Object]");
|
||||
expect(message).not.toContain(bearerToken);
|
||||
} finally {
|
||||
try {
|
||||
if (server.listening) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await monitor.stop();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("dispatches a signed POST to a configured trailing-slash webhook path", async () => {
|
||||
const monitor = await monitorLineProvider({
|
||||
channelAccessToken: "token",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import type { webhook } from "@line/bot-sdk";
|
||||
import { hasFinalInboundReplyDispatch } from "openclaw/plugin-sdk/channel-inbound";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { chunkMarkdownText } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import {
|
||||
danger,
|
||||
@@ -392,7 +393,7 @@ export async function monitorLineProvider(
|
||||
res.end(JSON.stringify({ error: requestBodyErrorToText("REQUEST_BODY_TIMEOUT") }));
|
||||
return;
|
||||
}
|
||||
runtime.error?.(danger(`line webhook error: ${String(err)}`));
|
||||
runtime.error?.(danger(`line webhook error: ${formatErrorMessage(err)}`));
|
||||
if (!res.headersSent) {
|
||||
res.statusCode = 500;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
|
||||
@@ -183,18 +183,19 @@ type WebhookPostResult = {
|
||||
};
|
||||
|
||||
type WebhookPostInvoker = (params: {
|
||||
failWith?: Error;
|
||||
failWith?: unknown;
|
||||
rawBody: string;
|
||||
signed: boolean;
|
||||
}) => Promise<WebhookPostResult>;
|
||||
|
||||
async function invokeNodePostContract(params: {
|
||||
failWith?: Error;
|
||||
failWith?: unknown;
|
||||
rawBody: string;
|
||||
signed: boolean;
|
||||
}) {
|
||||
const dispatched = vi.fn(async () => {
|
||||
if (params.failWith) {
|
||||
// oxlint-disable-next-line typescript/only-throw-error -- Webhook boundaries must report non-Error throws from downstream dispatchers.
|
||||
throw params.failWith;
|
||||
}
|
||||
});
|
||||
@@ -223,13 +224,14 @@ async function invokeNodePostContract(params: {
|
||||
}
|
||||
|
||||
async function invokeMiddlewarePostContract(params: {
|
||||
failWith?: Error;
|
||||
failWith?: unknown;
|
||||
rawBody: string;
|
||||
signed: boolean;
|
||||
}) {
|
||||
const runtime = createRuntimeMock();
|
||||
const onEvents = vi.fn(async () => {
|
||||
if (params.failWith) {
|
||||
// oxlint-disable-next-line typescript/only-throw-error -- Webhook boundaries must report non-Error throws from downstream dispatchers.
|
||||
throw params.failWith;
|
||||
}
|
||||
});
|
||||
@@ -354,6 +356,26 @@ describe("LINE webhook shared POST contract", () => {
|
||||
expect(result.runtimeError).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(sharedWebhookPostContractCases)(
|
||||
"$name reports non-Error admission failures without object Object",
|
||||
async ({ invoke }) => {
|
||||
const result = await invoke({
|
||||
failWith: { code: "LINE_ADMISSION_REJECTED", retryAfterMs: 250 },
|
||||
rawBody: JSON.stringify({ events: [{ type: "message" }] }),
|
||||
signed: true,
|
||||
});
|
||||
|
||||
expect(result.status).toBe(500);
|
||||
expect(result.body).toEqual({ error: "Internal server error" });
|
||||
expect(result.runtimeError).toHaveBeenCalledTimes(1);
|
||||
const runtimeMessage = String(firstMockCall(result.runtimeError, "runtime error")[0]);
|
||||
expect(runtimeMessage).toContain("line webhook error:");
|
||||
expect(runtimeMessage).toContain("LINE_ADMISSION_REJECTED");
|
||||
expect(runtimeMessage).toContain("retryAfterMs");
|
||||
expect(runtimeMessage).not.toContain("[object Object]");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("createLineNodeWebhookHandler", () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Line plugin module implements webhook node behavior.
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { webhook } from "@line/bot-sdk";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { danger, logVerbose, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||
import {
|
||||
isRequestBodyLimitError,
|
||||
@@ -119,7 +120,7 @@ export function createLineNodeWebhookHandler(params: {
|
||||
res.end(JSON.stringify({ error: requestBodyErrorToText("REQUEST_BODY_TIMEOUT") }));
|
||||
return;
|
||||
}
|
||||
params.runtime.error?.(danger(`line webhook error: ${String(err)}`));
|
||||
params.runtime.error?.(danger(`line webhook error: ${formatErrorMessage(err)}`));
|
||||
if (!res.headersSent) {
|
||||
res.statusCode = 500;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Line plugin module implements webhook behavior.
|
||||
import type { webhook } from "@line/bot-sdk";
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { danger, logVerbose, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { parseLineWebhookBody, validateLineSignature } from "./webhook-utils.js";
|
||||
|
||||
@@ -73,7 +74,7 @@ export function createLineWebhookMiddleware(
|
||||
}
|
||||
res.status(200).json({ status: "ok" });
|
||||
} catch (err) {
|
||||
runtime?.error?.(danger(`line webhook error: ${String(err)}`));
|
||||
runtime?.error?.(danger(`line webhook error: ${formatErrorMessage(err)}`));
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user