import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { handleWecomWebhookRequest, registerWecomWebhookTarget } from "./monitor.js";
import { encryptWecomPlaintext, computeWecomMsgSignature, WECOM_PKCS7_BLOCK_SIZE } from "./crypto.js";
import * as runtime from "./runtime.js";
import crypto from "node:crypto";
import { IncomingMessage, ServerResponse } from "node:http";
import { Socket } from "node:net";
const { undiciFetch } = vi.hoisted(() => {
const undiciFetch = vi.fn();
return { undiciFetch };
});
vi.mock("undici", () => ({
fetch: undiciFetch,
ProxyAgent: class ProxyAgent { },
}));
// Helpers to simulate HTTP request
function createMockRequest(bodyObj: any, query: URLSearchParams): IncomingMessage {
const socket = new Socket();
const req = new IncomingMessage(socket);
req.method = "POST";
req.url = `/wecom?${query.toString()}`;
req.push(JSON.stringify(bodyObj));
req.push(null);
return req;
}
function createMockResponse(): ServerResponse & { _getData: () => string, _getStatusCode: () => number } {
const req = new IncomingMessage(new Socket());
const res = new ServerResponse(req);
let data = "";
res.write = (chunk: any) => { data += chunk; return true; };
res.end = (chunk: any) => { if (chunk) data += chunk; return res; };
(res as any)._getData = () => data;
(res as any)._getStatusCode = () => res.statusCode;
return res as any;
}
// PKCS7 Pad Helper for manual encryption
function pkcs7Pad(buf: Buffer, blockSize: number): Buffer {
const mod = buf.length % blockSize;
const pad = mod === 0 ? blockSize : blockSize - mod;
const padByte = Buffer.from([pad]);
return Buffer.concat([buf, Buffer.alloc(pad, padByte[0]!)]);
}
describe("Monitor Integration: Inbound Image", () => {
const token = "MY_TOKEN";
const encodingAESKey = "jWmYm7qr5nMoCAstdRmNjt3p7vsH8HkK+qiJqQ0aaaa="; // 32 bytes key
const receiveId = "MY_CORPID";
let unregisterTarget: (() => void) | null = null;
// Mock Core Runtime
const mockDeliver = vi.fn();
const mockCore = {
channel: {
routing: { resolveAgentRoute: () => ({ agentId: "agent-1", sessionKey: "sess-1", accountId: "acc-1" }) },
commands: {
shouldComputeCommandAuthorized: () => false,
resolveCommandAuthorizedFromAuthorizers: () => true,
},
pairing: {
readAllowFromStore: async () => [],
},
session: {
resolveStorePath: () => "store/path",
readSessionUpdatedAt: () => 0,
recordInboundSession: vi.fn(),
},
reply: {
formatAgentEnvelope: () => "formatted-body",
finalizeInboundContext: (ctx: any) => ctx,
resolveEnvelopeFormatOptions: () => ({}),
dispatchReplyWithBufferedBlockDispatcher: async (opts: any) => {
// Simulate Agent processing by calling deliver immediately or later
// For this test, verifying the Inbound Body is enough.
// The delivery payload is what the AGENT sees.
// But wait, dispatchReply... is for OUTBOUND streaming replies.
// startAgentForStream calls it.
// We really want to spy on what `rawBody` was passed to startAgentForStream context.
// Actually `recordInboundSession` receives `ctx` which contains `RawBody`.
return;
},
},
text: { resolveMarkdownTableMode: () => "off", convertMarkdownTables: (t: string) => t },
},
logging: { shouldLogVerbose: () => true },
};
beforeEach(() => {
vi.spyOn(runtime, "getWecomRuntime").mockReturnValue(mockCore as any);
unregisterTarget?.();
unregisterTarget = registerWecomWebhookTarget({
account: {
accountId: "test-acc",
name: "Test",
enabled: true,
configured: true,
token,
encodingAESKey,
receiveId,
config: {} as any
},
config: {} as any,
runtime: { log: console.log, error: console.error },
core: mockCore as any,
path: "/wecom"
});
});
afterEach(() => {
unregisterTarget?.();
unregisterTarget = null;
vi.restoreAllMocks();
});
// Mock media saving
const mockSaveMediaBuffer = vi.fn().mockResolvedValue({ path: "/tmp/saved-image.jpg", contentType: "image/jpeg" });
(mockCore.channel as any).media = { saveMediaBuffer: mockSaveMediaBuffer };
it("should decrypt inbound image, save it, and inject into context", async () => {
// 1. Prepare Encrypted Media (The "File" on WeCom Server)
const fileContent = Buffer.from("fake-image-data");
const aesKey = Buffer.from(encodingAESKey + "=", "base64");
const iv = aesKey.subarray(0, 16);
// Encrypt content (WeCom does this)
const cipher = crypto.createCipheriv("aes-256-cbc", aesKey, iv);
cipher.setAutoPadding(false);
const encryptedMedia = Buffer.concat([cipher.update(pkcs7Pad(fileContent, WECOM_PKCS7_BLOCK_SIZE)), cipher.final()]);
// Mock HTTP fetch to return this encrypted media
undiciFetch.mockResolvedValue(new Response(encryptedMedia));
// 2. Prepare Inbound Message (The Webhook JSON)
const imageUrl = "http://wecom.server/media/123";
const inboundMsg = {
msgtype: "image",
image: { url: imageUrl },
from: { userid: "testuser" }
};
// 3. Encrypt the *Inbound Message* Payload (The Envelope)
const timestamp = String(Math.floor(Date.now() / 1000));
const nonce = "123456";
const encrypt = encryptWecomPlaintext({
encodingAESKey,
receiveId,
plaintext: JSON.stringify(inboundMsg)
});
const msgSignature = computeWecomMsgSignature({ token, timestamp, nonce, encrypt });
const query = new URLSearchParams({
msg_signature: msgSignature,
timestamp,
nonce
});
const bodyObj = {
touser: receiveId,
agentid: "10001",
encrypt, // Standard WeCom POST body structure
};
// 4. Send Request
const req = createMockRequest(bodyObj, query);
const res = createMockResponse();
await handleWecomWebhookRequest(req, res);
// Wait for debounce timer to trigger agent (DEFAULT_DEBOUNCE_MS = 500ms)
await new Promise(resolve => setTimeout(resolve, 600));
// 5. Verify
// Check recordInboundSession was called with correct RawBody and Media Context
expect(mockCore.channel.session.recordInboundSession).toHaveBeenCalled();
const recordCall = (mockCore.channel.session.recordInboundSession as any).mock.calls[0][0];
const ctx = recordCall.ctx;
// Expect: [image]
expect(ctx.RawBody).toBe("[image]");
// Expect media to be saved
expect(mockSaveMediaBuffer).toHaveBeenCalledWith(
expect.any(Buffer), // The decrypted buffer
"image/jpeg",
"inbound",
expect.any(Number), // maxBytes
"image.jpg"
);
const savedBuffer = mockSaveMediaBuffer.mock.calls[0][0];
expect(savedBuffer.toString()).toBe("fake-image-data");
// Expect Context Injection
expect(ctx.MediaPath).toBe("/tmp/saved-image.jpg");
expect(ctx.MediaType).toBe("image/jpeg");
expect(undiciFetch).toHaveBeenCalledWith(
imageUrl,
expect.objectContaining({ signal: expect.anything() }),
);
});
});