import crypto from "node:crypto"; import { createServer } from "node:http"; import type { AddressInfo } from "node:net"; import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createFeishuRuntimeMockModule } from "./monitor.test-mocks.js"; const probeFeishuMock = vi.hoisted(() => vi.fn()); vi.mock("./probe.js", () => ({ probeFeishu: probeFeishuMock, })); vi.mock("./client.js", async () => { const actual = await vi.importActual("./client.js"); return { ...actual, createFeishuWSClient: vi.fn(() => ({ start: vi.fn() })), }; }); vi.mock("./runtime.js", () => createFeishuRuntimeMockModule()); import { monitorFeishuProvider, stopFeishuMonitor } from "./monitor.js"; async function getFreePort(): Promise { const server = createServer(); await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve())); const address = server.address() as AddressInfo | null; if (!address) { throw new Error("missing server address"); } await new Promise((resolve) => server.close(() => resolve())); return address.port; } async function waitUntilServerReady(url: string): Promise { for (let i = 0; i < 50; i += 1) { try { const response = await fetch(url, { method: "GET" }); if (response.status >= 200 && response.status < 500) { return; } } catch { // retry } await new Promise((resolve) => setTimeout(resolve, 20)); } throw new Error(`server did not start: ${url}`); } function buildConfig(params: { accountId: string; path: string; port: number; verificationToken?: string; encryptKey?: string; }): ClawdbotConfig { return { channels: { feishu: { enabled: true, accounts: { [params.accountId]: { enabled: true, appId: "cli_test", appSecret: "secret_test", // pragma: allowlist secret connectionMode: "webhook", webhookHost: "127.0.0.1", webhookPort: params.port, webhookPath: params.path, encryptKey: params.encryptKey, verificationToken: params.verificationToken, }, }, }, }, } as ClawdbotConfig; } function signFeishuPayload(params: { encryptKey: string; payload: Record; timestamp?: string; nonce?: string; }): Record { const timestamp = params.timestamp ?? "1711111111"; const nonce = params.nonce ?? "nonce-test"; const signature = crypto .createHash("sha256") .update(timestamp + nonce + params.encryptKey + JSON.stringify(params.payload)) .digest("hex"); return { "content-type": "application/json", "x-lark-request-timestamp": timestamp, "x-lark-request-nonce": nonce, "x-lark-signature": signature, }; } function encryptFeishuPayload(encryptKey: string, payload: Record): string { const iv = crypto.randomBytes(16); const key = crypto.createHash("sha256").update(encryptKey).digest(); const cipher = crypto.createCipheriv("aes-256-cbc", key, iv); const plaintext = Buffer.from(JSON.stringify(payload), "utf8"); const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]); return Buffer.concat([iv, encrypted]).toString("base64"); } async function withRunningWebhookMonitor( params: { accountId: string; path: string; verificationToken: string; encryptKey: string; }, run: (url: string) => Promise, ) { const port = await getFreePort(); const cfg = buildConfig({ accountId: params.accountId, path: params.path, port, encryptKey: params.encryptKey, verificationToken: params.verificationToken, }); const abortController = new AbortController(); const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; const monitorPromise = monitorFeishuProvider({ config: cfg, runtime, abortSignal: abortController.signal, }); const url = `http://127.0.0.1:${port}${params.path}`; await waitUntilServerReady(url); try { await run(url); } finally { abortController.abort(); await monitorPromise; } } afterEach(() => { stopFeishuMonitor(); }); describe("Feishu webhook signed-request e2e", () => { it("rejects invalid signatures with 401 instead of empty 200", async () => { probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" }); await withRunningWebhookMonitor( { accountId: "invalid-signature", path: "/hook-e2e-invalid-signature", verificationToken: "verify_token", encryptKey: "encrypt_key", }, async (url) => { const payload = { type: "url_verification", challenge: "challenge-token" }; const response = await fetch(url, { method: "POST", headers: { ...signFeishuPayload({ encryptKey: "wrong_key", payload }), }, body: JSON.stringify(payload), }); expect(response.status).toBe(401); expect(await response.text()).toBe("Invalid signature"); }, ); }); it("rejects missing signature headers with 401", async () => { probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" }); await withRunningWebhookMonitor( { accountId: "missing-signature", path: "/hook-e2e-missing-signature", verificationToken: "verify_token", encryptKey: "encrypt_key", }, async (url) => { const response = await fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ type: "url_verification", challenge: "challenge-token" }), }); expect(response.status).toBe(401); expect(await response.text()).toBe("Invalid signature"); }, ); }); it("returns 400 for invalid json before invoking the sdk", async () => { probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" }); await withRunningWebhookMonitor( { accountId: "invalid-json", path: "/hook-e2e-invalid-json", verificationToken: "verify_token", encryptKey: "encrypt_key", }, async (url) => { const response = await fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: "{not-json", }); expect(response.status).toBe(400); expect(await response.text()).toBe("Invalid JSON"); }, ); }); it("accepts signed plaintext url_verification challenges end-to-end", async () => { probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" }); await withRunningWebhookMonitor( { accountId: "signed-challenge", path: "/hook-e2e-signed-challenge", verificationToken: "verify_token", encryptKey: "encrypt_key", }, async (url) => { const payload = { type: "url_verification", challenge: "challenge-token" }; const response = await fetch(url, { method: "POST", headers: signFeishuPayload({ encryptKey: "encrypt_key", payload }), body: JSON.stringify(payload), }); expect(response.status).toBe(200); await expect(response.json()).resolves.toEqual({ challenge: "challenge-token" }); }, ); }); it("accepts signed non-challenge events and reaches the dispatcher", async () => { probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" }); await withRunningWebhookMonitor( { accountId: "signed-dispatch", path: "/hook-e2e-signed-dispatch", verificationToken: "verify_token", encryptKey: "encrypt_key", }, async (url) => { const payload = { schema: "2.0", header: { event_type: "unknown.event" }, event: {}, }; const response = await fetch(url, { method: "POST", headers: signFeishuPayload({ encryptKey: "encrypt_key", payload }), body: JSON.stringify(payload), }); expect(response.status).toBe(200); expect(await response.text()).toContain("no unknown.event event handle"); }, ); }); it("accepts signed encrypted url_verification challenges end-to-end", async () => { probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" }); await withRunningWebhookMonitor( { accountId: "encrypted-challenge", path: "/hook-e2e-encrypted-challenge", verificationToken: "verify_token", encryptKey: "encrypt_key", }, async (url) => { const payload = { encrypt: encryptFeishuPayload("encrypt_key", { type: "url_verification", challenge: "encrypted-challenge-token", }), }; const response = await fetch(url, { method: "POST", headers: signFeishuPayload({ encryptKey: "encrypt_key", payload }), body: JSON.stringify(payload), }); expect(response.status).toBe(200); await expect(response.json()).resolves.toEqual({ challenge: "encrypted-challenge-token", }); }, ); }); });