import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { sendActiveMessage, handleWecomWebhookRequest, registerWecomWebhookTarget } from "./monitor.js"; import * as cryptoHelpers from "./crypto.js"; import * as runtime from "./runtime.js"; import * as agentApi from "./transport/agent-api/core.js"; import { IncomingMessage, ServerResponse } from "node:http"; import { Socket } from "node:net"; import * as crypto from "node:crypto"; const { undiciFetch } = vi.hoisted(() => { const undiciFetch = vi.fn(); return { undiciFetch }; }); vi.mock("undici", () => ({ fetch: undiciFetch, ProxyAgent: class ProxyAgent { }, })); vi.mock("./transport/agent-api/core.js", () => ({ sendText: vi.fn(), sendMedia: vi.fn(), uploadMedia: vi.fn(), })); // Helpers function createMockRequest(bodyObj: any): IncomingMessage { const socket = new Socket(); const req = new IncomingMessage(socket); req.method = "POST"; req.url = "/plugins/wecom/bot/default?timestamp=123&nonce=456&signature=789"; req.push(JSON.stringify(bodyObj)); req.push(null); return req; } function createMockResponse(): ServerResponse { const req = new IncomingMessage(new Socket()); const res = new ServerResponse(req); res.end = vi.fn() as any; res.setHeader = vi.fn(); (res as any).statusCode = 200; return res; } describe("Monitor Active Features", () => { let capturedDeliver: ((payload: { text: string }) => Promise) | undefined; let unregisterTarget: (() => void) | undefined; let mockCore: any; let msgSeq = 0; let senderUserId = ""; let senderChatId = ""; // Valid 32-byte AES Key (Base64 encoded) const validKey = "jWmYm7qr5nMoCAstdRmNjt3p7vsH8HkK+qiJqQ0aaaa="; beforeEach(() => { vi.useFakeTimers(); capturedDeliver = undefined; vi.restoreAllMocks(); undiciFetch.mockClear(); msgSeq += 1; senderUserId = `zhangsan-${msgSeq}`; senderChatId = `wr123-${msgSeq}`; // Spy on crypto.randomBytes (default export in monitor.ts usage) vi.spyOn(crypto.default, "randomBytes").mockImplementation((size) => { return Buffer.alloc(size, 0x11); }); // Mock Crypto Helpers // Wespy on verifyWecomSignature to always pass vi.spyOn(cryptoHelpers, "verifyWecomSignature").mockReturnValue(true); // We spy on decryptWecomEncrypted to return our mock plaintext // Note: For this to work despite direct import in monitor.ts, we rely on Vitest's // module mocking capabilities or the fact that * exports might be live bindings. // If this fails, we will know. vi.spyOn(cryptoHelpers, "decryptWecomEncrypted").mockImplementation((opts) => { return JSON.stringify({ msgid: `test-msg-id-${msgSeq}`, aibotid: "bot-1", chattype: "group", chatid: senderChatId, from: { userid: senderUserId }, msgtype: "text", text: { content: "hello" }, response_url: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test-key" }); }); mockCore = { channel: { text: { resolveMarkdownTableMode: () => "off", convertMarkdownTables: (t: string) => t.replace(/\|/g, "-") }, commands: { shouldComputeCommandAuthorized: () => false, resolveCommandAuthorizedFromAuthorizers: () => true, }, pairing: { readAllowFromStore: async () => [], }, reply: { finalizeInboundContext: (c: any) => c, resolveEnvelopeFormatOptions: () => ({}), formatAgentEnvelope: () => "", dispatchReplyWithBufferedBlockDispatcher: async (opts: any) => { capturedDeliver = opts.dispatcherOptions.deliver; return; } }, routing: { resolveAgentRoute: () => ({ agentId: "1", sessionKey: "1", accountId: "default" }) }, session: { resolveStorePath: () => "", readSessionUpdatedAt: () => 0, recordInboundSession: vi.fn() } }, logging: { shouldLogVerbose: () => false } }; vi.spyOn(runtime, "getWecomRuntime").mockReturnValue(mockCore); unregisterTarget = registerWecomWebhookTarget({ account: { accountId: "default", configured: true, token: "T", encodingAESKey: validKey, receiveId: "R", config: {} as any }, config: { channels: { wecom: { enabled: true, agent: { corpId: "corp", corpSecret: "secret", agentId: 1000002, token: "token", encodingAESKey: "aes", }, }, }, } as any, runtime: { log: () => { } }, core: mockCore, path: "/plugins/wecom/bot/default" }); }); afterEach(() => { unregisterTarget?.(); unregisterTarget = undefined; vi.useRealTimers(); }); it("should protect tags from table conversion", async () => { const req = createMockRequest({ encrypt: "mock-encrypt" }); const res = createMockResponse(); await handleWecomWebhookRequest(req, res); // The WeCom monitor debounces inbound messages before starting the agent. // `flushPending` triggers async agent start without awaiting it, so give the // microtask queue a chance to run after the timer fires. await vi.runOnlyPendingTimersAsync(); await Promise.resolve(); await Promise.resolve(); expect(capturedDeliver).toBeDefined(); const payload = { text: "Out | side\nInside | Think" }; const convertSpy = vi.spyOn(mockCore.channel.text, "convertMarkdownTables"); await capturedDeliver!(payload); const calledArg = convertSpy.mock.calls[0][0]; expect(calledArg).toContain("__THINK_PLACEHOLDER_0__"); expect(calledArg).not.toContain(""); }); it("should store response_url and allow active message sending", async () => { const req = createMockRequest({ encrypt: "mock-encrypt" }); const res = createMockResponse(); // We use a real key but mocked randomBytes. // However, `handleWecomWebhookRequest` calls `buildEncryptedJsonReply` -> `encryptWecomPlaintext`. // `encryptWecomPlaintext` uses the key. Since it's valid, it should work fine. // We don't verify the OUTPUT of handleWecomWebhookRequest, just that it runs and sets up state. await handleWecomWebhookRequest(req, res); const streamId = Buffer.alloc(16, 0x11).toString("hex"); undiciFetch.mockResolvedValue(new Response("ok", { status: 200 })); await sendActiveMessage(streamId, "Active Hello"); expect(undiciFetch).toHaveBeenCalled(); const [url, init] = undiciFetch.mock.calls.at(-1)! as [string, RequestInit]; expect(url).toBe("https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test-key"); expect(init).toEqual( expect.objectContaining({ method: "POST", body: JSON.stringify({ msgtype: "text", text: { content: "Active Hello" } }), }), ); const headers = new Headers(init.headers); expect(headers.get("content-type")).toBe("application/json"); }); it("should fallback non-image media to agent DM (and push a Chinese prompt)", async () => { const { uploadMedia, sendMedia } = agentApi as any; uploadMedia.mockResolvedValue("media-id-1"); sendMedia.mockResolvedValue(undefined); const req = createMockRequest({ encrypt: "mock-encrypt" }); const res = createMockResponse(); await handleWecomWebhookRequest(req, res); await vi.advanceTimersByTimeAsync(600); await Promise.resolve(); await Promise.resolve(); expect(capturedDeliver).toBeDefined(); // Create a local PDF to force non-image content-type inference. const fs = await import("node:fs/promises"); const os = await import("node:os"); const path = await import("node:path"); const tmp = path.join(os.tmpdir(), `wecom-test-${Date.now()}.pdf`); await fs.writeFile(tmp, Buffer.from("pdf")); undiciFetch.mockResolvedValue(new Response("ok", { status: 200 })); await capturedDeliver!({ text: "here", mediaUrls: [tmp] } as any); expect(uploadMedia).toHaveBeenCalled(); expect(sendMedia).toHaveBeenCalledWith( expect.objectContaining({ toUser: senderUserId, mediaType: "file", }), ); // Ensure we attempted to push a prompt to response_url (uses undici fetch). expect(undiciFetch).toHaveBeenCalled(); }); // 注:本机路径(/Users/...、/tmp/...、/root/...、/home/...)短路发图逻辑属于运行态特性, // 单测在 fake timers + module singleton 状态下容易引入脆弱性;这里优先覆盖更关键的兜底链路与去重逻辑。 });