mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-26 09:21:55 +00:00
test: collapse line channel suites
This commit is contained in:
@@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.");
|
||||
});
|
||||
});
|
||||
@@ -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.");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user