test: collapse line channel suites

This commit is contained in:
Peter Steinberger
2026-03-25 04:23:35 +00:00
parent ed9646516d
commit cb76ba2406
13 changed files with 784 additions and 816 deletions

View File

@@ -1,223 +0,0 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import ts from "typescript";
import { describe, expect, it } from "vitest";
import { loadRuntimeApiExportTypesViaJiti } from "../../test/helpers/extensions/jiti-runtime-api.ts";
function normalizeModuleSpecifier(specifier: string): string | null {
if (specifier.startsWith("./src/")) {
return specifier;
}
if (specifier.startsWith("../../extensions/line/src/")) {
return `./src/${specifier.slice("../../extensions/line/src/".length)}`;
}
return null;
}
function collectModuleExportNames(filePath: string): string[] {
const sourcePath = filePath.replace(/\.js$/, ".ts");
const sourceText = readFileSync(sourcePath, "utf8");
const sourceFile = ts.createSourceFile(sourcePath, sourceText, ts.ScriptTarget.Latest, true);
const names = new Set<string>();
for (const statement of sourceFile.statements) {
if (
ts.isExportDeclaration(statement) &&
statement.exportClause &&
ts.isNamedExports(statement.exportClause)
) {
for (const element of statement.exportClause.elements) {
if (!element.isTypeOnly) {
names.add(element.name.text);
}
}
continue;
}
const modifiers = ts.canHaveModifiers(statement) ? ts.getModifiers(statement) : undefined;
const isExported = modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword);
if (!isExported) {
continue;
}
if (ts.isVariableStatement(statement)) {
for (const declaration of statement.declarationList.declarations) {
if (ts.isIdentifier(declaration.name)) {
names.add(declaration.name.text);
}
}
continue;
}
if (
ts.isFunctionDeclaration(statement) ||
ts.isClassDeclaration(statement) ||
ts.isEnumDeclaration(statement)
) {
if (statement.name) {
names.add(statement.name.text);
}
}
}
return Array.from(names).toSorted();
}
function collectRuntimeApiOverlapExports(params: {
lineRuntimePath: string;
runtimeApiPath: string;
}): string[] {
const runtimeApiSource = readFileSync(params.runtimeApiPath, "utf8");
const runtimeApiFile = ts.createSourceFile(
params.runtimeApiPath,
runtimeApiSource,
ts.ScriptTarget.Latest,
true,
);
const runtimeApiLocalModules = new Set<string>();
let pluginSdkLineRuntimeSeen = false;
for (const statement of runtimeApiFile.statements) {
if (!ts.isExportDeclaration(statement)) {
continue;
}
const moduleSpecifier =
statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier)
? statement.moduleSpecifier.text
: undefined;
if (!moduleSpecifier) {
continue;
}
if (moduleSpecifier === "openclaw/plugin-sdk/line-runtime") {
pluginSdkLineRuntimeSeen = true;
continue;
}
if (!pluginSdkLineRuntimeSeen) {
continue;
}
const normalized = normalizeModuleSpecifier(moduleSpecifier);
if (normalized) {
runtimeApiLocalModules.add(normalized);
}
}
const lineRuntimeSource = readFileSync(params.lineRuntimePath, "utf8");
const lineRuntimeFile = ts.createSourceFile(
params.lineRuntimePath,
lineRuntimeSource,
ts.ScriptTarget.Latest,
true,
);
const overlapExports = new Set<string>();
for (const statement of lineRuntimeFile.statements) {
if (!ts.isExportDeclaration(statement)) {
continue;
}
const moduleSpecifier =
statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier)
? statement.moduleSpecifier.text
: undefined;
const normalized = moduleSpecifier ? normalizeModuleSpecifier(moduleSpecifier) : null;
if (!normalized || !runtimeApiLocalModules.has(normalized)) {
continue;
}
if (!statement.exportClause) {
for (const name of collectModuleExportNames(
path.join(process.cwd(), "extensions", "line", normalized),
)) {
overlapExports.add(name);
}
continue;
}
if (!ts.isNamedExports(statement.exportClause)) {
continue;
}
for (const element of statement.exportClause.elements) {
if (!element.isTypeOnly) {
overlapExports.add(element.name.text);
}
}
}
return Array.from(overlapExports).toSorted();
}
function collectRuntimeApiPreExports(runtimeApiPath: string): string[] {
const runtimeApiSource = readFileSync(runtimeApiPath, "utf8");
const runtimeApiFile = ts.createSourceFile(
runtimeApiPath,
runtimeApiSource,
ts.ScriptTarget.Latest,
true,
);
const preExports = new Set<string>();
for (const statement of runtimeApiFile.statements) {
if (!ts.isExportDeclaration(statement)) {
continue;
}
const moduleSpecifier =
statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier)
? statement.moduleSpecifier.text
: undefined;
if (!moduleSpecifier) {
continue;
}
if (moduleSpecifier === "openclaw/plugin-sdk/line-runtime") {
break;
}
const normalized = normalizeModuleSpecifier(moduleSpecifier);
if (!normalized || !statement.exportClause || !ts.isNamedExports(statement.exportClause)) {
continue;
}
for (const element of statement.exportClause.elements) {
if (!element.isTypeOnly) {
preExports.add(element.name.text);
}
}
}
return Array.from(preExports).toSorted();
}
describe("line runtime api", () => {
it("loads through Jiti without duplicate export errors", () => {
const runtimeApiPath = path.join(process.cwd(), "extensions", "line", "runtime-api.ts");
expect(
loadRuntimeApiExportTypesViaJiti({
modulePath: runtimeApiPath,
exportNames: [
"buildTemplateMessageFromPayload",
"downloadLineMedia",
"isSenderAllowed",
"probeLineBot",
"pushMessageLine",
],
realPluginSdkSpecifiers: ["openclaw/plugin-sdk/line-runtime"],
}),
).toEqual({
buildTemplateMessageFromPayload: "function",
downloadLineMedia: "function",
isSenderAllowed: "function",
probeLineBot: "function",
pushMessageLine: "function",
});
}, 240_000);
it("keeps the LINE pre-export block aligned with plugin-sdk/line-runtime overlap", () => {
const runtimeApiPath = path.join(process.cwd(), "extensions", "line", "runtime-api.ts");
const lineRuntimePath = path.join(process.cwd(), "src", "plugin-sdk", "line-runtime.ts");
expect(collectRuntimeApiPreExports(runtimeApiPath)).toEqual(
collectRuntimeApiOverlapExports({
lineRuntimePath,
runtimeApiPath,
}),
);
});
});

View File

@@ -1,96 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { createStartAccountContext } from "../../../test/helpers/extensions/start-account-context.js";
import type { PluginRuntime, ResolvedLineAccount } from "../api.js";
import { linePlugin } from "./channel.js";
import { setLineRuntime } from "./runtime.js";
function createRuntime() {
const monitorLineProvider = vi.fn(async () => ({
account: { accountId: "default" },
handleWebhook: async () => {},
stop: () => {},
}));
const runtime = {
channel: {
line: {
monitorLineProvider,
},
},
logging: {
shouldLogVerbose: () => false,
},
} as unknown as PluginRuntime;
return { runtime, monitorLineProvider };
}
function createAccount(params: { token: string; secret: string }): ResolvedLineAccount {
return {
accountId: "default",
enabled: true,
channelAccessToken: params.token,
channelSecret: params.secret,
tokenSource: "config",
config: {} as ResolvedLineAccount["config"],
};
}
function startLineAccount(params: { account: ResolvedLineAccount; abortSignal?: AbortSignal }) {
const { runtime, monitorLineProvider } = createRuntime();
setLineRuntime(runtime);
return {
monitorLineProvider,
task: linePlugin.gateway!.startAccount!(
createStartAccountContext({
account: params.account,
abortSignal: params.abortSignal,
}),
),
};
}
describe("linePlugin gateway.startAccount", () => {
it("fails startup when channel secret is missing", async () => {
const { monitorLineProvider, task } = startLineAccount({
account: createAccount({ token: "token", secret: " " }),
});
await expect(task).rejects.toThrow(
'LINE webhook mode requires a non-empty channel secret for account "default".',
);
expect(monitorLineProvider).not.toHaveBeenCalled();
});
it("fails startup when channel access token is missing", async () => {
const { monitorLineProvider, task } = startLineAccount({
account: createAccount({ token: " ", secret: "secret" }),
});
await expect(task).rejects.toThrow(
'LINE webhook mode requires a non-empty channel access token for account "default".',
);
expect(monitorLineProvider).not.toHaveBeenCalled();
});
it("starts provider when token and secret are present", async () => {
const abort = new AbortController();
const { monitorLineProvider, task } = startLineAccount({
account: createAccount({ token: "token", secret: "secret" }),
abortSignal: abort.signal,
});
await vi.waitFor(() => {
expect(monitorLineProvider).toHaveBeenCalledWith(
expect.objectContaining({
channelAccessToken: "token",
channelSecret: "secret",
accountId: "default",
}),
);
});
abort.abort();
await task;
});
});

View File

@@ -1,95 +0,0 @@
import { describe, expect, it } from "vitest";
import {
createInfoCard,
createListCard,
createImageCard,
createActionCard,
createCarousel,
createEventCard,
createDeviceControlCard,
} from "./flex-templates.js";
describe("createInfoCard", () => {
it("includes footer when provided", () => {
const card = createInfoCard("Title", "Body", "Footer text");
const footer = card.footer as { contents: Array<{ text: string }> };
expect(footer.contents[0].text).toBe("Footer text");
});
});
describe("createListCard", () => {
it("limits items to 8", () => {
const items = Array.from({ length: 15 }, (_, i) => ({ title: `Item ${i}` }));
const card = createListCard("List", items);
const body = card.body as { contents: Array<{ type: string; contents?: unknown[] }> };
// The list items are in the third content (after title and separator)
const listBox = body.contents[2] as { contents: unknown[] };
expect(listBox.contents.length).toBe(8);
});
});
describe("createImageCard", () => {
it("includes body text when provided", () => {
const card = createImageCard("https://example.com/img.jpg", "Title", "Body text");
const body = card.body as { contents: Array<{ text: string }> };
expect(body.contents.length).toBe(2);
expect(body.contents[1].text).toBe("Body text");
});
});
describe("createActionCard", () => {
it("limits actions to 4", () => {
const actions = Array.from({ length: 6 }, (_, i) => ({
label: `Action ${i}`,
action: { type: "message" as const, label: `A${i}`, text: `action${i}` },
}));
const card = createActionCard("Title", "Body", actions);
const footer = card.footer as { contents: unknown[] };
expect(footer.contents.length).toBe(4);
});
});
describe("createCarousel", () => {
it("limits to 12 bubbles", () => {
const bubbles = Array.from({ length: 15 }, (_, i) => createInfoCard(`Card ${i}`, `Body ${i}`));
const carousel = createCarousel(bubbles);
expect(carousel.contents.length).toBe(12);
});
});
describe("createDeviceControlCard", () => {
it("limits controls to 6", () => {
const card = createDeviceControlCard({
deviceName: "Device",
controls: Array.from({ length: 10 }, (_, i) => ({
label: `Control ${i}`,
data: `action=${i}`,
})),
});
// Should have max 3 rows of 2 buttons
const footer = card.footer as { contents: unknown[] };
expect(footer.contents.length).toBeLessThanOrEqual(3);
});
});
describe("createEventCard", () => {
it("includes all optional fields together", () => {
const card = createEventCard({
title: "Team Offsite",
date: "February 15, 2026",
time: "9:00 AM - 5:00 PM",
location: "Mountain View Office",
description: "Annual team building event",
});
expect(card.size).toBe("mega");
const body = card.body as { contents: Array<{ type: string }> };
expect(body.contents).toHaveLength(3);
});
});

View File

@@ -6,6 +6,7 @@ import {
resolveLineGroupLookupIds,
resolveLineGroupsConfig,
} from "./group-keys.js";
import { resolveLineGroupRequireMention } from "./group-policy.js";
describe("resolveLineGroupLookupIds", () => {
it("expands raw ids to both prefixed candidates", () => {
@@ -77,3 +78,58 @@ describe("account-scoped LINE groups", () => {
);
});
});
describe("line group policy", () => {
it("matches raw and prefixed LINE group keys for requireMention", () => {
const cfg = {
channels: {
line: {
groups: {
"room:r123": {
requireMention: false,
},
"group:g123": {
requireMention: false,
},
"*": {
requireMention: true,
},
},
},
},
// oxlint-disable-next-line typescript/no-explicit-any
} as any;
expect(resolveLineGroupRequireMention({ cfg, groupId: "r123" })).toBe(false);
expect(resolveLineGroupRequireMention({ cfg, groupId: "room:r123" })).toBe(false);
expect(resolveLineGroupRequireMention({ cfg, groupId: "g123" })).toBe(false);
expect(resolveLineGroupRequireMention({ cfg, groupId: "group:g123" })).toBe(false);
expect(resolveLineGroupRequireMention({ cfg, groupId: "other" })).toBe(true);
});
it("uses account-scoped prefixed LINE group config for requireMention", () => {
const cfg = {
channels: {
line: {
groups: {
"*": {
requireMention: true,
},
},
accounts: {
work: {
groups: {
"group:g123": {
requireMention: false,
},
},
},
},
},
},
// oxlint-disable-next-line typescript/no-explicit-any
} as any;
expect(resolveLineGroupRequireMention({ cfg, groupId: "g123", accountId: "work" })).toBe(false);
});
});

View File

@@ -1,57 +0,0 @@
import { describe, expect, it } from "vitest";
import { resolveLineGroupRequireMention } from "./group-policy.js";
describe("line group policy", () => {
it("matches raw and prefixed LINE group keys for requireMention", () => {
const cfg = {
channels: {
line: {
groups: {
"room:r123": {
requireMention: false,
},
"group:g123": {
requireMention: false,
},
"*": {
requireMention: true,
},
},
},
},
// oxlint-disable-next-line typescript/no-explicit-any
} as any;
expect(resolveLineGroupRequireMention({ cfg, groupId: "r123" })).toBe(false);
expect(resolveLineGroupRequireMention({ cfg, groupId: "room:r123" })).toBe(false);
expect(resolveLineGroupRequireMention({ cfg, groupId: "g123" })).toBe(false);
expect(resolveLineGroupRequireMention({ cfg, groupId: "group:g123" })).toBe(false);
expect(resolveLineGroupRequireMention({ cfg, groupId: "other" })).toBe(true);
});
it("uses account-scoped prefixed LINE group config for requireMention", () => {
const cfg = {
channels: {
line: {
groups: {
"*": {
requireMention: true,
},
},
accounts: {
work: {
groups: {
"group:g123": {
requireMention: false,
},
},
},
},
},
},
// oxlint-disable-next-line typescript/no-explicit-any
} as any;
expect(resolveLineGroupRequireMention({ cfg, groupId: "g123", accountId: "work" })).toBe(false);
});
});

View File

@@ -1,4 +1,13 @@
import { describe, expect, it } from "vitest";
import {
createActionCard,
createCarousel,
createDeviceControlCard,
createEventCard,
createImageCard,
createInfoCard,
createListCard,
} from "./flex-templates.js";
import {
createConfirmTemplate,
createButtonTemplate,
@@ -122,3 +131,74 @@ describe("createProductCarousel", () => {
expect(columns[0].actions[0].type).toBe(expectedType);
});
});
describe("flex cards", () => {
it("includes footer when provided", () => {
const card = createInfoCard("Title", "Body", "Footer text");
const footer = card.footer as { contents: Array<{ text: string }> };
expect(footer.contents[0].text).toBe("Footer text");
});
it("limits list items to 8", () => {
const items = Array.from({ length: 15 }, (_, i) => ({ title: `Item ${i}` }));
const card = createListCard("List", items);
const body = card.body as { contents: Array<{ type: string; contents?: unknown[] }> };
const listBox = body.contents[2] as { contents: unknown[] };
expect(listBox.contents.length).toBe(8);
});
it("includes image-card body text when provided", () => {
const card = createImageCard("https://example.com/img.jpg", "Title", "Body text");
const body = card.body as { contents: Array<{ text: string }> };
expect(body.contents.length).toBe(2);
expect(body.contents[1].text).toBe("Body text");
});
it("limits action-card actions to 4", () => {
const actions = Array.from({ length: 6 }, (_, i) => ({
label: `Action ${i}`,
action: { type: "message" as const, label: `A${i}`, text: `action${i}` },
}));
const card = createActionCard("Title", "Body", actions);
const footer = card.footer as { contents: unknown[] };
expect(footer.contents.length).toBe(4);
});
it("limits carousels to 12 bubbles", () => {
const bubbles = Array.from({ length: 15 }, (_, i) => createInfoCard(`Card ${i}`, `Body ${i}`));
const carousel = createCarousel(bubbles);
expect(carousel.contents.length).toBe(12);
});
it("limits device controls to 6", () => {
const card = createDeviceControlCard({
deviceName: "Device",
controls: Array.from({ length: 10 }, (_, i) => ({
label: `Control ${i}`,
data: `action=${i}`,
})),
});
const footer = card.footer as { contents: unknown[] };
expect(footer.contents.length).toBeLessThanOrEqual(3);
});
it("keeps event-card optional fields together", () => {
const card = createEventCard({
title: "Team Offsite",
date: "February 15, 2026",
time: "9:00 AM - 5:00 PM",
location: "Mountain View Office",
description: "Annual team building event",
});
expect(card.size).toBe("mega");
const body = card.body as { contents: Array<{ type: string }> };
expect(body.contents).toHaveLength(3);
});
});

View File

@@ -1,28 +0,0 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { describe, expect, it } from "vitest";
import { monitorLineProvider } from "./monitor.js";
describe("monitorLineProvider fail-closed webhook auth", () => {
it("rejects startup when channel secret is missing", async () => {
await expect(
monitorLineProvider({
channelAccessToken: "token",
channelSecret: " ",
config: {} as OpenClawConfig,
runtime: {} as RuntimeEnv,
}),
).rejects.toThrow("LINE webhook mode requires a non-empty channel secret.");
});
it("rejects startup when channel access token is missing", async () => {
await expect(
monitorLineProvider({
channelAccessToken: " ",
channelSecret: "secret",
config: {} as OpenClawConfig,
runtime: {} as RuntimeEnv,
}),
).rejects.toThrow("LINE webhook mode requires a non-empty channel access token.");
});
});

View File

@@ -142,4 +142,26 @@ describe("monitorLineProvider lifecycle", () => {
monitor.stop();
expect(unregisterHttpMock).toHaveBeenCalledTimes(1);
});
it("rejects startup when channel secret is missing", async () => {
await expect(
monitorLineProvider({
channelAccessToken: "token",
channelSecret: " ",
config: {} as OpenClawConfig,
runtime: {} as RuntimeEnv,
}),
).rejects.toThrow("LINE webhook mode requires a non-empty channel secret.");
});
it("rejects startup when channel access token is missing", async () => {
await expect(
monitorLineProvider({
channelAccessToken: " ",
channelSecret: "secret",
config: {} as OpenClawConfig,
runtime: {} as RuntimeEnv,
}),
).rejects.toThrow("LINE webhook mode requires a non-empty channel access token.");
});
});

View File

@@ -1,16 +0,0 @@
import { describe, expect, it } from "vitest";
import { createMockIncomingRequest } from "../../../test/helpers/mock-incoming-request.js";
import { readLineWebhookRequestBody } from "./webhook-node.js";
describe("readLineWebhookRequestBody", () => {
it("reads body within limit", async () => {
const req = createMockIncomingRequest(['{"events":[{"type":"message"}]}']);
const body = await readLineWebhookRequestBody(req, 1024);
expect(body).toContain('"events"');
});
it("rejects oversized body", async () => {
const req = createMockIncomingRequest(["x".repeat(2048)]);
await expect(readLineWebhookRequestBody(req, 128)).rejects.toThrow("PayloadTooLarge");
});
});

View File

@@ -1,57 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { getBotInfoMock, MessagingApiClientMock } = vi.hoisted(() => {
const getBotInfoMock = vi.fn();
const MessagingApiClientMock = vi.fn(function () {
return { getBotInfo: getBotInfoMock };
});
return { getBotInfoMock, MessagingApiClientMock };
});
vi.mock("@line/bot-sdk", () => ({
messagingApi: { MessagingApiClient: MessagingApiClientMock },
}));
let probeLineBot: typeof import("./probe.js").probeLineBot;
afterEach(() => {
vi.useRealTimers();
getBotInfoMock.mockClear();
});
describe("probeLineBot", () => {
beforeEach(async () => {
vi.resetModules();
getBotInfoMock.mockReset();
MessagingApiClientMock.mockReset();
MessagingApiClientMock.mockImplementation(function () {
return { getBotInfo: getBotInfoMock };
});
({ probeLineBot } = await import("./probe.js"));
});
it("returns timeout when bot info stalls", async () => {
vi.useFakeTimers();
getBotInfoMock.mockImplementation(() => new Promise(() => {}));
const probePromise = probeLineBot("token", 10);
await vi.advanceTimersByTimeAsync(20);
const result = await probePromise;
expect(result.ok).toBe(false);
expect(result.error).toBe("timeout");
});
it("returns bot info when available", async () => {
getBotInfoMock.mockResolvedValue({
displayName: "OpenClaw",
userId: "U123",
basicId: "@openclaw",
pictureUrl: "https://example.com/bot.png",
});
const result = await probeLineBot("token", 50);
expect(result.ok).toBe(true);
expect(result.bot?.userId).toBe("U123");
});
});

View File

@@ -1,14 +1,213 @@
import { describe, expect, it, vi } from "vitest";
import { readFileSync } from "node:fs";
import path from "node:path";
import ts from "typescript";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { loadRuntimeApiExportTypesViaJiti } from "../../../test/helpers/extensions/jiti-runtime-api.ts";
import {
createPluginSetupWizardConfigure,
createTestWizardPrompter,
runSetupWizardConfigure,
type WizardPrompter,
} from "../../../test/helpers/extensions/setup-wizard.js";
import type { OpenClawConfig } from "../api.js";
import { createStartAccountContext } from "../../../test/helpers/extensions/start-account-context.js";
import type { OpenClawConfig, PluginRuntime, ResolvedLineAccount } from "../api.js";
import { linePlugin } from "./channel.js";
import { setLineRuntime } from "./runtime.js";
const { getBotInfoMock, MessagingApiClientMock } = vi.hoisted(() => {
const getBotInfoMock = vi.fn();
const MessagingApiClientMock = vi.fn(function () {
return { getBotInfo: getBotInfoMock };
});
return { getBotInfoMock, MessagingApiClientMock };
});
vi.mock("@line/bot-sdk", () => ({
messagingApi: { MessagingApiClient: MessagingApiClientMock },
}));
const lineConfigure = createPluginSetupWizardConfigure(linePlugin);
let probeLineBot: typeof import("./probe.js").probeLineBot;
function normalizeModuleSpecifier(specifier: string): string | null {
if (specifier.startsWith("./src/")) {
return specifier;
}
if (specifier.startsWith("../../extensions/line/src/")) {
return `./src/${specifier.slice("../../extensions/line/src/".length)}`;
}
return null;
}
function collectModuleExportNames(filePath: string): string[] {
const sourcePath = filePath.replace(/\.js$/, ".ts");
const sourceText = readFileSync(sourcePath, "utf8");
const sourceFile = ts.createSourceFile(sourcePath, sourceText, ts.ScriptTarget.Latest, true);
const names = new Set<string>();
for (const statement of sourceFile.statements) {
if (
ts.isExportDeclaration(statement) &&
statement.exportClause &&
ts.isNamedExports(statement.exportClause)
) {
for (const element of statement.exportClause.elements) {
if (!element.isTypeOnly) {
names.add(element.name.text);
}
}
continue;
}
const modifiers = ts.canHaveModifiers(statement) ? ts.getModifiers(statement) : undefined;
const isExported = modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword);
if (!isExported) {
continue;
}
if (ts.isVariableStatement(statement)) {
for (const declaration of statement.declarationList.declarations) {
if (ts.isIdentifier(declaration.name)) {
names.add(declaration.name.text);
}
}
continue;
}
if (
ts.isFunctionDeclaration(statement) ||
ts.isClassDeclaration(statement) ||
ts.isEnumDeclaration(statement)
) {
if (statement.name) {
names.add(statement.name.text);
}
}
}
return Array.from(names).toSorted();
}
function collectRuntimeApiOverlapExports(params: {
lineRuntimePath: string;
runtimeApiPath: string;
}): string[] {
const runtimeApiSource = readFileSync(params.runtimeApiPath, "utf8");
const runtimeApiFile = ts.createSourceFile(
params.runtimeApiPath,
runtimeApiSource,
ts.ScriptTarget.Latest,
true,
);
const runtimeApiLocalModules = new Set<string>();
let pluginSdkLineRuntimeSeen = false;
for (const statement of runtimeApiFile.statements) {
if (!ts.isExportDeclaration(statement)) {
continue;
}
const moduleSpecifier =
statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier)
? statement.moduleSpecifier.text
: undefined;
if (!moduleSpecifier) {
continue;
}
if (moduleSpecifier === "openclaw/plugin-sdk/line-runtime") {
pluginSdkLineRuntimeSeen = true;
continue;
}
if (!pluginSdkLineRuntimeSeen) {
continue;
}
const normalized = normalizeModuleSpecifier(moduleSpecifier);
if (normalized) {
runtimeApiLocalModules.add(normalized);
}
}
const lineRuntimeSource = readFileSync(params.lineRuntimePath, "utf8");
const lineRuntimeFile = ts.createSourceFile(
params.lineRuntimePath,
lineRuntimeSource,
ts.ScriptTarget.Latest,
true,
);
const overlapExports = new Set<string>();
for (const statement of lineRuntimeFile.statements) {
if (!ts.isExportDeclaration(statement)) {
continue;
}
const moduleSpecifier =
statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier)
? statement.moduleSpecifier.text
: undefined;
const normalized = moduleSpecifier ? normalizeModuleSpecifier(moduleSpecifier) : null;
if (!normalized || !runtimeApiLocalModules.has(normalized)) {
continue;
}
if (!statement.exportClause) {
for (const name of collectModuleExportNames(
path.join(process.cwd(), "extensions", "line", normalized),
)) {
overlapExports.add(name);
}
continue;
}
if (!ts.isNamedExports(statement.exportClause)) {
continue;
}
for (const element of statement.exportClause.elements) {
if (!element.isTypeOnly) {
overlapExports.add(element.name.text);
}
}
}
return Array.from(overlapExports).toSorted();
}
function collectRuntimeApiPreExports(runtimeApiPath: string): string[] {
const runtimeApiSource = readFileSync(runtimeApiPath, "utf8");
const runtimeApiFile = ts.createSourceFile(
runtimeApiPath,
runtimeApiSource,
ts.ScriptTarget.Latest,
true,
);
const preExports = new Set<string>();
for (const statement of runtimeApiFile.statements) {
if (!ts.isExportDeclaration(statement)) {
continue;
}
const moduleSpecifier =
statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier)
? statement.moduleSpecifier.text
: undefined;
if (!moduleSpecifier) {
continue;
}
if (moduleSpecifier === "openclaw/plugin-sdk/line-runtime") {
break;
}
const normalized = normalizeModuleSpecifier(moduleSpecifier);
if (!normalized || !statement.exportClause || !ts.isNamedExports(statement.exportClause)) {
continue;
}
for (const element of statement.exportClause.elements) {
if (!element.isTypeOnly) {
preExports.add(element.name.text);
}
}
}
return Array.from(preExports).toSorted();
}
describe("line setup wizard", () => {
it("configures token and secret for the default account", async () => {
@@ -37,3 +236,175 @@ describe("line setup wizard", () => {
expect(result.cfg.channels?.line?.channelSecret).toBe("line-secret");
});
});
describe("probeLineBot", () => {
beforeEach(async () => {
vi.resetModules();
getBotInfoMock.mockReset();
MessagingApiClientMock.mockReset();
MessagingApiClientMock.mockImplementation(function () {
return { getBotInfo: getBotInfoMock };
});
({ probeLineBot } = await import("./probe.js"));
});
afterEach(() => {
vi.useRealTimers();
getBotInfoMock.mockClear();
});
it("returns timeout when bot info stalls", async () => {
vi.useFakeTimers();
getBotInfoMock.mockImplementation(() => new Promise(() => {}));
const probePromise = probeLineBot("token", 10);
await vi.advanceTimersByTimeAsync(20);
const result = await probePromise;
expect(result.ok).toBe(false);
expect(result.error).toBe("timeout");
});
it("returns bot info when available", async () => {
getBotInfoMock.mockResolvedValue({
displayName: "OpenClaw",
userId: "U123",
basicId: "@openclaw",
pictureUrl: "https://example.com/bot.png",
});
const result = await probeLineBot("token", 50);
expect(result.ok).toBe(true);
expect(result.bot?.userId).toBe("U123");
});
});
describe("line runtime api", () => {
it("loads through Jiti without duplicate export errors", () => {
const runtimeApiPath = path.join(process.cwd(), "extensions", "line", "runtime-api.ts");
expect(
loadRuntimeApiExportTypesViaJiti({
modulePath: runtimeApiPath,
exportNames: [
"buildTemplateMessageFromPayload",
"downloadLineMedia",
"isSenderAllowed",
"probeLineBot",
"pushMessageLine",
],
realPluginSdkSpecifiers: ["openclaw/plugin-sdk/line-runtime"],
}),
).toEqual({
buildTemplateMessageFromPayload: "function",
downloadLineMedia: "function",
isSenderAllowed: "function",
probeLineBot: "function",
pushMessageLine: "function",
});
}, 240_000);
it("keeps the LINE pre-export block aligned with plugin-sdk/line-runtime overlap", () => {
const runtimeApiPath = path.join(process.cwd(), "extensions", "line", "runtime-api.ts");
const lineRuntimePath = path.join(process.cwd(), "src", "plugin-sdk", "line-runtime.ts");
expect(collectRuntimeApiPreExports(runtimeApiPath)).toEqual(
collectRuntimeApiOverlapExports({
lineRuntimePath,
runtimeApiPath,
}),
);
});
});
function createRuntime() {
const monitorLineProvider = vi.fn(async () => ({
account: { accountId: "default" },
handleWebhook: async () => {},
stop: () => {},
}));
const runtime = {
channel: {
line: {
monitorLineProvider,
},
},
logging: {
shouldLogVerbose: () => false,
},
} as unknown as PluginRuntime;
return { runtime, monitorLineProvider };
}
function createAccount(params: { token: string; secret: string }): ResolvedLineAccount {
return {
accountId: "default",
enabled: true,
channelAccessToken: params.token,
channelSecret: params.secret,
tokenSource: "config",
config: {} as ResolvedLineAccount["config"],
};
}
function startLineAccount(params: { account: ResolvedLineAccount; abortSignal?: AbortSignal }) {
const { runtime, monitorLineProvider } = createRuntime();
setLineRuntime(runtime);
return {
monitorLineProvider,
task: linePlugin.gateway!.startAccount!(
createStartAccountContext({
account: params.account,
abortSignal: params.abortSignal,
}),
),
};
}
describe("linePlugin gateway.startAccount", () => {
it("fails startup when channel secret is missing", async () => {
const { monitorLineProvider, task } = startLineAccount({
account: createAccount({ token: "token", secret: " " }),
});
await expect(task).rejects.toThrow(
'LINE webhook mode requires a non-empty channel secret for account "default".',
);
expect(monitorLineProvider).not.toHaveBeenCalled();
});
it("fails startup when channel access token is missing", async () => {
const { monitorLineProvider, task } = startLineAccount({
account: createAccount({ token: " ", secret: "secret" }),
});
await expect(task).rejects.toThrow(
'LINE webhook mode requires a non-empty channel access token for account "default".',
);
expect(monitorLineProvider).not.toHaveBeenCalled();
});
it("starts provider when token and secret are present", async () => {
const abort = new AbortController();
const { monitorLineProvider, task } = startLineAccount({
account: createAccount({ token: "token", secret: "secret" }),
abortSignal: abort.signal,
});
await vi.waitFor(() => {
expect(monitorLineProvider).toHaveBeenCalledWith(
expect.objectContaining({
channelAccessToken: "token",
channelSecret: "secret",
accountId: "default",
}),
);
});
abort.abort();
await task;
});
});

View File

@@ -1,7 +1,10 @@
import crypto from "node:crypto";
import type { IncomingMessage, ServerResponse } from "node:http";
import { describe, expect, it, vi } from "vitest";
import { createMockIncomingRequest } from "../../../test/helpers/mock-incoming-request.js";
import { createLineNodeWebhookHandler } from "./webhook-node.js";
import { readLineWebhookRequestBody } from "./webhook-node.js";
import { createLineWebhookMiddleware, startLineWebhook } from "./webhook.js";
const sign = (body: string, secret: string) =>
crypto.createHmac("SHA256", secret).update(body).digest("base64");
@@ -25,6 +28,20 @@ function createRes() {
return { res, headers };
}
const SECRET = "secret";
function createMiddlewareRes() {
const res = {
status: vi.fn(),
json: vi.fn(),
headersSent: false,
// oxlint-disable-next-line typescript/no-explicit-any
} as any;
res.status.mockReturnValue(res);
res.json.mockReturnValue(res);
return res;
}
function createPostWebhookTestHarness(rawBody: string, secret = "secret") {
const bot = { handleWebhook: vi.fn(async () => {}) };
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
@@ -51,6 +68,70 @@ const runSignedPost = async (params: {
params.res,
);
async function invokeWebhook(params: {
body: unknown;
headers?: Record<string, string>;
onEvents?: ReturnType<typeof vi.fn>;
autoSign?: boolean;
}) {
const onEventsMock = params.onEvents ?? vi.fn(async () => {});
const middleware = createLineWebhookMiddleware({
channelSecret: SECRET,
onEvents: onEventsMock as never,
});
const headers = { ...params.headers };
const autoSign = params.autoSign ?? true;
if (autoSign && !headers["x-line-signature"]) {
if (typeof params.body === "string") {
headers["x-line-signature"] = sign(params.body, SECRET);
} else if (Buffer.isBuffer(params.body)) {
headers["x-line-signature"] = sign(params.body.toString("utf-8"), SECRET);
}
}
const req = {
headers,
body: params.body,
// oxlint-disable-next-line typescript/no-explicit-any
} as any;
const res = createMiddlewareRes();
// oxlint-disable-next-line typescript/no-explicit-any
await middleware(req, res, {} as any);
return { res, onEvents: onEventsMock };
}
async function expectSignedRawBodyWins(params: { rawBody: string | Buffer; signedUserId: string }) {
const onEvents = vi.fn(async () => {});
const reqBody = {
events: [{ type: "message", source: { userId: "tampered-user" } }],
};
const middleware = createLineWebhookMiddleware({
channelSecret: SECRET,
onEvents,
});
const rawBodyText =
typeof params.rawBody === "string" ? params.rawBody : params.rawBody.toString("utf-8");
const req = {
headers: { "x-line-signature": sign(rawBodyText, SECRET) },
rawBody: params.rawBody,
body: reqBody,
// oxlint-disable-next-line typescript/no-explicit-any
} as any;
const res = createMiddlewareRes();
// oxlint-disable-next-line typescript/no-explicit-any
await middleware(req, res, {} as any);
expect(res.status).toHaveBeenCalledWith(200);
expect(onEvents).toHaveBeenCalledTimes(1);
const processedBody = (
onEvents.mock.calls[0] as unknown as [{ events?: Array<{ source?: { userId?: string } }> }]
)?.[0];
expect(processedBody?.events?.[0]?.source?.userId).toBe(params.signedUserId);
expect(processedBody?.events?.[0]?.source?.userId).not.toBe("tampered-user");
}
describe("createLineNodeWebhookHandler", () => {
it("returns 200 for GET", async () => {
const bot = { handleWebhook: vi.fn(async () => {}) };
@@ -241,3 +322,175 @@ describe("createLineNodeWebhookHandler", () => {
expect(bot.handleWebhook).not.toHaveBeenCalled();
});
});
describe("readLineWebhookRequestBody", () => {
it("reads body within limit", async () => {
const req = createMockIncomingRequest(['{"events":[{"type":"message"}]}']);
const body = await readLineWebhookRequestBody(req, 1024);
expect(body).toContain('"events"');
});
it("rejects oversized body", async () => {
const req = createMockIncomingRequest(["x".repeat(2048)]);
await expect(readLineWebhookRequestBody(req, 128)).rejects.toThrow("PayloadTooLarge");
});
});
describe("createLineWebhookMiddleware", () => {
it("rejects startup when channel secret is missing", () => {
expect(() =>
startLineWebhook({
channelSecret: " ",
onEvents: async () => {},
}),
).toThrow(/requires a non-empty channel secret/i);
});
it.each([
["raw string body", JSON.stringify({ events: [{ type: "message" }] })],
["raw buffer body", Buffer.from(JSON.stringify({ events: [{ type: "follow" }] }), "utf-8")],
])("parses JSON from %s", async (_label, body) => {
const { res, onEvents } = await invokeWebhook({ body });
expect(res.status).toHaveBeenCalledWith(200);
expect(onEvents).toHaveBeenCalledWith(expect.objectContaining({ events: expect.any(Array) }));
});
it("rejects invalid JSON payloads", async () => {
const { res, onEvents } = await invokeWebhook({ body: "not json" });
expect(res.status).toHaveBeenCalledWith(400);
expect(onEvents).not.toHaveBeenCalled();
});
it("rejects webhooks with invalid signatures", async () => {
const { res, onEvents } = await invokeWebhook({
body: JSON.stringify({ events: [{ type: "message" }] }),
headers: { "x-line-signature": "invalid-signature" },
});
expect(res.status).toHaveBeenCalledWith(401);
expect(onEvents).not.toHaveBeenCalled();
});
it("rejects verification-shaped requests without a signature", async () => {
const { res, onEvents } = await invokeWebhook({
body: JSON.stringify({ events: [] }),
headers: {},
autoSign: false,
});
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({ error: "Missing X-Line-Signature header" });
expect(onEvents).not.toHaveBeenCalled();
});
it("accepts signed verification-shaped requests without dispatching events", async () => {
const { res, onEvents } = await invokeWebhook({
body: JSON.stringify({ events: [] }),
});
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith({ status: "ok" });
expect(onEvents).not.toHaveBeenCalled();
});
it("rejects oversized signed payloads before JSON parsing", async () => {
const largeBody = JSON.stringify({ events: [], payload: "x".repeat(70 * 1024) });
const { res, onEvents } = await invokeWebhook({ body: largeBody });
expect(res.status).toHaveBeenCalledWith(413);
expect(res.json).toHaveBeenCalledWith({ error: "Payload too large" });
expect(onEvents).not.toHaveBeenCalled();
});
it("rejects missing signature when events are non-empty", async () => {
const { res, onEvents } = await invokeWebhook({
body: JSON.stringify({ events: [{ type: "message" }] }),
headers: {},
autoSign: false,
});
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({ error: "Missing X-Line-Signature header" });
expect(onEvents).not.toHaveBeenCalled();
});
it("rejects signed requests when raw body is missing", async () => {
const { res, onEvents } = await invokeWebhook({
body: { events: [{ type: "message" }] },
headers: { "x-line-signature": "signed" },
});
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
error: "Missing raw request body for signature verification",
});
expect(onEvents).not.toHaveBeenCalled();
});
it("uses the signed raw body instead of a pre-parsed req.body object", async () => {
await expectSignedRawBodyWins({
rawBody: JSON.stringify({
events: [{ type: "message", source: { userId: "signed-user" } }],
}),
signedUserId: "signed-user",
});
});
it("uses signed raw buffer body instead of a pre-parsed req.body object", async () => {
await expectSignedRawBodyWins({
rawBody: Buffer.from(
JSON.stringify({
events: [{ type: "message", source: { userId: "signed-buffer-user" } }],
}),
"utf-8",
),
signedUserId: "signed-buffer-user",
});
});
it("rejects invalid signed raw JSON even when req.body is a valid object", async () => {
const onEvents = vi.fn(async () => {});
const rawBody = "not-json";
const middleware = createLineWebhookMiddleware({
channelSecret: SECRET,
onEvents,
});
const req = {
headers: { "x-line-signature": sign(rawBody, SECRET) },
rawBody,
body: { events: [{ type: "message" }] },
// oxlint-disable-next-line typescript/no-explicit-any
} as any;
const res = createMiddlewareRes();
// oxlint-disable-next-line typescript/no-explicit-any
await middleware(req, res, {} as any);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({ error: "Invalid webhook payload" });
expect(onEvents).not.toHaveBeenCalled();
});
it("returns 500 when event processing fails and does not acknowledge with 200", async () => {
const onEvents = vi.fn(async () => {
throw new Error("boom");
});
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
const rawBody = JSON.stringify({ events: [{ type: "message" }] });
const middleware = createLineWebhookMiddleware({
channelSecret: SECRET,
onEvents,
runtime,
});
const req = {
headers: { "x-line-signature": sign(rawBody, SECRET) },
body: rawBody,
// oxlint-disable-next-line typescript/no-explicit-any
} as any;
const res = createMiddlewareRes();
// oxlint-disable-next-line typescript/no-explicit-any
await middleware(req, res, {} as any);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.status).not.toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith({ error: "Internal server error" });
expect(runtime.error).toHaveBeenCalled();
});
});

View File

@@ -1,242 +0,0 @@
import crypto from "node:crypto";
import type { WebhookRequestBody } from "@line/bot-sdk";
import { describe, expect, it, vi } from "vitest";
import { createLineWebhookMiddleware, startLineWebhook } from "./webhook.js";
const sign = (body: string, secret: string) =>
crypto.createHmac("SHA256", secret).update(body).digest("base64");
const createRes = () => {
const res = {
status: vi.fn(),
json: vi.fn(),
headersSent: false,
// oxlint-disable-next-line typescript/no-explicit-any
} as any;
res.status.mockReturnValue(res);
res.json.mockReturnValue(res);
return res;
};
const SECRET = "secret";
async function invokeWebhook(params: {
body: unknown;
headers?: Record<string, string>;
onEvents?: ReturnType<typeof vi.fn>;
autoSign?: boolean;
}) {
const onEventsMock = params.onEvents ?? vi.fn(async () => {});
const middleware = createLineWebhookMiddleware({
channelSecret: SECRET,
onEvents: onEventsMock as unknown as (body: WebhookRequestBody) => Promise<void>,
});
const headers = { ...params.headers };
const autoSign = params.autoSign ?? true;
if (autoSign && !headers["x-line-signature"]) {
if (typeof params.body === "string") {
headers["x-line-signature"] = sign(params.body, SECRET);
} else if (Buffer.isBuffer(params.body)) {
headers["x-line-signature"] = sign(params.body.toString("utf-8"), SECRET);
}
}
const req = {
headers,
body: params.body,
// oxlint-disable-next-line typescript/no-explicit-any
} as any;
const res = createRes();
// oxlint-disable-next-line typescript/no-explicit-any
await middleware(req, res, {} as any);
return { res, onEvents: onEventsMock };
}
async function expectSignedRawBodyWins(params: { rawBody: string | Buffer; signedUserId: string }) {
const onEvents = vi.fn(async (_body: WebhookRequestBody) => {});
const reqBody = {
events: [{ type: "message", source: { userId: "tampered-user" } }],
};
const middleware = createLineWebhookMiddleware({
channelSecret: SECRET,
onEvents,
});
const rawBodyText =
typeof params.rawBody === "string" ? params.rawBody : params.rawBody.toString("utf-8");
const req = {
headers: { "x-line-signature": sign(rawBodyText, SECRET) },
rawBody: params.rawBody,
body: reqBody,
// oxlint-disable-next-line typescript/no-explicit-any
} as any;
const res = createRes();
// oxlint-disable-next-line typescript/no-explicit-any
await middleware(req, res, {} as any);
expect(res.status).toHaveBeenCalledWith(200);
expect(onEvents).toHaveBeenCalledTimes(1);
const processedBody = onEvents.mock.calls[0]?.[0] as WebhookRequestBody | undefined;
expect(processedBody?.events?.[0]?.source?.userId).toBe(params.signedUserId);
expect(processedBody?.events?.[0]?.source?.userId).not.toBe("tampered-user");
}
describe("createLineWebhookMiddleware", () => {
it("rejects startup when channel secret is missing", () => {
expect(() =>
startLineWebhook({
channelSecret: " ",
onEvents: async () => {},
}),
).toThrow(/requires a non-empty channel secret/i);
});
it.each([
["raw string body", JSON.stringify({ events: [{ type: "message" }] })],
["raw buffer body", Buffer.from(JSON.stringify({ events: [{ type: "follow" }] }), "utf-8")],
])("parses JSON from %s", async (_label, body) => {
const { res, onEvents } = await invokeWebhook({ body });
expect(res.status).toHaveBeenCalledWith(200);
expect(onEvents).toHaveBeenCalledWith(expect.objectContaining({ events: expect.any(Array) }));
});
it("rejects invalid JSON payloads", async () => {
const { res, onEvents } = await invokeWebhook({ body: "not json" });
expect(res.status).toHaveBeenCalledWith(400);
expect(onEvents).not.toHaveBeenCalled();
});
it("rejects webhooks with invalid signatures", async () => {
const { res, onEvents } = await invokeWebhook({
body: JSON.stringify({ events: [{ type: "message" }] }),
headers: { "x-line-signature": "invalid-signature" },
});
expect(res.status).toHaveBeenCalledWith(401);
expect(onEvents).not.toHaveBeenCalled();
});
it("rejects verification-shaped requests without a signature", async () => {
const { res, onEvents } = await invokeWebhook({
body: JSON.stringify({ events: [] }),
headers: {},
autoSign: false,
});
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({ error: "Missing X-Line-Signature header" });
expect(onEvents).not.toHaveBeenCalled();
});
it("accepts signed verification-shaped requests without dispatching events", async () => {
const { res, onEvents } = await invokeWebhook({
body: JSON.stringify({ events: [] }),
});
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith({ status: "ok" });
expect(onEvents).not.toHaveBeenCalled();
});
it("rejects oversized signed payloads before JSON parsing", async () => {
const largeBody = JSON.stringify({ events: [], payload: "x".repeat(70 * 1024) });
const { res, onEvents } = await invokeWebhook({ body: largeBody });
expect(res.status).toHaveBeenCalledWith(413);
expect(res.json).toHaveBeenCalledWith({ error: "Payload too large" });
expect(onEvents).not.toHaveBeenCalled();
});
it("rejects missing signature when events are non-empty", async () => {
const { res, onEvents } = await invokeWebhook({
body: JSON.stringify({ events: [{ type: "message" }] }),
headers: {},
autoSign: false,
});
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({ error: "Missing X-Line-Signature header" });
expect(onEvents).not.toHaveBeenCalled();
});
it("rejects signed requests when raw body is missing", async () => {
const { res, onEvents } = await invokeWebhook({
body: { events: [{ type: "message" }] },
headers: { "x-line-signature": "signed" },
});
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
error: "Missing raw request body for signature verification",
});
expect(onEvents).not.toHaveBeenCalled();
});
it("uses the signed raw body instead of a pre-parsed req.body object", async () => {
await expectSignedRawBodyWins({
rawBody: JSON.stringify({
events: [{ type: "message", source: { userId: "signed-user" } }],
}),
signedUserId: "signed-user",
});
});
it("uses signed raw buffer body instead of a pre-parsed req.body object", async () => {
await expectSignedRawBodyWins({
rawBody: Buffer.from(
JSON.stringify({
events: [{ type: "message", source: { userId: "signed-buffer-user" } }],
}),
"utf-8",
),
signedUserId: "signed-buffer-user",
});
});
it("rejects invalid signed raw JSON even when req.body is a valid object", async () => {
const onEvents = vi.fn(async (_body: WebhookRequestBody) => {});
const rawBody = "not-json";
const middleware = createLineWebhookMiddleware({
channelSecret: SECRET,
onEvents,
});
const req = {
headers: { "x-line-signature": sign(rawBody, SECRET) },
rawBody,
body: { events: [{ type: "message" }] },
// oxlint-disable-next-line typescript/no-explicit-any
} as any;
const res = createRes();
// oxlint-disable-next-line typescript/no-explicit-any
await middleware(req, res, {} as any);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({ error: "Invalid webhook payload" });
expect(onEvents).not.toHaveBeenCalled();
});
it("returns 500 when event processing fails and does not acknowledge with 200", async () => {
const onEvents = vi.fn(async () => {
throw new Error("boom");
});
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
const rawBody = JSON.stringify({ events: [{ type: "message" }] });
const middleware = createLineWebhookMiddleware({
channelSecret: SECRET,
onEvents,
runtime,
});
const req = {
headers: { "x-line-signature": sign(rawBody, SECRET) },
body: rawBody,
// oxlint-disable-next-line typescript/no-explicit-any
} as any;
const res = createRes();
// oxlint-disable-next-line typescript/no-explicit-any
await middleware(req, res, {} as any);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.status).not.toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith({ error: "Internal server error" });
expect(runtime.error).toHaveBeenCalled();
});
});