diff --git a/extensions/line/index.test.ts b/extensions/line/index.test.ts deleted file mode 100644 index 52f27d3a5cb..00000000000 --- a/extensions/line/index.test.ts +++ /dev/null @@ -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(); - - 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(); - 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(); - - 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(); - - 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, - }), - ); - }); -}); diff --git a/extensions/line/src/channel.startup.test.ts b/extensions/line/src/channel.startup.test.ts deleted file mode 100644 index 399ec7de716..00000000000 --- a/extensions/line/src/channel.startup.test.ts +++ /dev/null @@ -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; - }); -}); diff --git a/extensions/line/src/flex-templates.test.ts b/extensions/line/src/flex-templates.test.ts deleted file mode 100644 index fe5e168e34c..00000000000 --- a/extensions/line/src/flex-templates.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/extensions/line/src/group-keys.test.ts b/extensions/line/src/group-keys.test.ts index a35f6126b4e..b902d34bee4 100644 --- a/extensions/line/src/group-keys.test.ts +++ b/extensions/line/src/group-keys.test.ts @@ -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); + }); +}); diff --git a/extensions/line/src/group-policy.test.ts b/extensions/line/src/group-policy.test.ts deleted file mode 100644 index b9fa64321aa..00000000000 --- a/extensions/line/src/group-policy.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/extensions/line/src/template-messages.test.ts b/extensions/line/src/message-cards.test.ts similarity index 59% rename from extensions/line/src/template-messages.test.ts rename to extensions/line/src/message-cards.test.ts index b142b2a765d..23a150e4e08 100644 --- a/extensions/line/src/template-messages.test.ts +++ b/extensions/line/src/message-cards.test.ts @@ -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); + }); +}); diff --git a/extensions/line/src/monitor.fail-closed.test.ts b/extensions/line/src/monitor.fail-closed.test.ts deleted file mode 100644 index 379ff46e849..00000000000 --- a/extensions/line/src/monitor.fail-closed.test.ts +++ /dev/null @@ -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."); - }); -}); diff --git a/extensions/line/src/monitor.lifecycle.test.ts b/extensions/line/src/monitor.lifecycle.test.ts index 94e2217e44c..37a6a25dadb 100644 --- a/extensions/line/src/monitor.lifecycle.test.ts +++ b/extensions/line/src/monitor.lifecycle.test.ts @@ -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."); + }); }); diff --git a/extensions/line/src/monitor.read-body.test.ts b/extensions/line/src/monitor.read-body.test.ts deleted file mode 100644 index 90c3007321d..00000000000 --- a/extensions/line/src/monitor.read-body.test.ts +++ /dev/null @@ -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"); - }); -}); diff --git a/extensions/line/src/probe.test.ts b/extensions/line/src/probe.test.ts deleted file mode 100644 index ec28d3a3996..00000000000 --- a/extensions/line/src/probe.test.ts +++ /dev/null @@ -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"); - }); -}); diff --git a/extensions/line/src/setup-surface.test.ts b/extensions/line/src/setup-surface.test.ts index 0eeb85f9eb5..1e2c7d3836e 100644 --- a/extensions/line/src/setup-surface.test.ts +++ b/extensions/line/src/setup-surface.test.ts @@ -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(); + + 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(); + 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(); + + 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(); + + 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; + }); +}); diff --git a/extensions/line/src/webhook-node.test.ts b/extensions/line/src/webhook-node.test.ts index e4d8d7870f5..cca45787ff2 100644 --- a/extensions/line/src/webhook-node.test.ts +++ b/extensions/line/src/webhook-node.test.ts @@ -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; + onEvents?: ReturnType; + 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(); + }); +}); diff --git a/extensions/line/src/webhook.test.ts b/extensions/line/src/webhook.test.ts deleted file mode 100644 index 954e96c6b8c..00000000000 --- a/extensions/line/src/webhook.test.ts +++ /dev/null @@ -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; - onEvents?: ReturnType; - autoSign?: boolean; -}) { - const onEventsMock = params.onEvents ?? vi.fn(async () => {}); - const middleware = createLineWebhookMiddleware({ - channelSecret: SECRET, - onEvents: onEventsMock as unknown as (body: WebhookRequestBody) => Promise, - }); - - 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(); - }); -});