Files
openclaw/extensions/synology-chat/src/client.loopback.test.ts
T
DaigoSoup 75830f027a fix(synology-chat): deliver long messages without webhook rejection (#116645)
* fix(synology-chat): chunk long messages before webhook delivery

* test(synology-chat): expose chunk limit in channel mock
2026-08-01 01:51:52 -07:00

389 lines
13 KiB
TypeScript

import { once } from "node:events";
import * as http from "node:http";
import { afterEach, describe, expect, it, vi } from "vitest";
import { synologyChatPlugin } from "./channel.js";
import { resolveLegacyWebhookNameToChatUserId, sendMessage } from "./client.js";
const USER_LIST_RESPONSE_MAX_BYTES = 1 * 1024 * 1024;
describe("Synology Chat user_list loopback", () => {
let server: http.Server | undefined;
async function listenLoopback(handler: http.RequestListener): Promise<number> {
server = http.createServer(handler);
server.on("clientError", (_err, socket) => socket.destroy());
server.listen(0, "127.0.0.1");
await once(server, "listening");
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("expected loopback server address");
}
return address.port;
}
afterEach(async () => {
vi.restoreAllMocks();
if (server) {
await new Promise<void>((resolve, reject) => {
server?.close((err) => (err ? reject(err) : resolve()));
server?.closeAllConnections?.();
});
server = undefined;
}
});
it("delivers authenticated text and media without inventing platform message ids", async () => {
const receivedPayloads: Array<Record<string, unknown>> = [];
const port = await listenLoopback((req, res) => {
const requestUrl = new URL(req.url ?? "/", "http://127.0.0.1");
if (
req.method !== "POST" ||
requestUrl.pathname !== "/webapi/entry.cgi" ||
requestUrl.searchParams.get("api") !== "SYNO.Chat.External" ||
requestUrl.searchParams.get("method") !== "chatbot" ||
requestUrl.searchParams.get("version") !== "2" ||
requestUrl.searchParams.get("token") !== "synology-loopback-proof"
) {
res.writeHead(403, { "Content-Type": "application/json" });
res.end(JSON.stringify({ success: false }));
return;
}
let formBody = "";
req.setEncoding("utf8");
req.on("data", (chunk: string) => {
formBody += chunk;
});
req.on("end", () => {
const payload = new URLSearchParams(formBody).get("payload");
if (!payload) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ success: false }));
return;
}
receivedPayloads.push(JSON.parse(payload) as Record<string, unknown>);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ success: true }));
});
});
const incomingUrl =
`http://127.0.0.1:${port}/webapi/entry.cgi?` +
"api=SYNO.Chat.External&method=chatbot&version=2&token=synology-loopback-proof";
const cfg = {
channels: {
"synology-chat": {
enabled: true,
token: "synology-loopback-proof",
incomingUrl,
},
},
};
const mediaUrl = "https://example.com/synology-receipt-proof.png";
const outboundText = await synologyChatPlugin.outbound.sendText({
cfg,
text: "native outbound text",
to: "42",
});
const outboundMedia = await synologyChatPlugin.outbound.sendMedia({
cfg,
mediaUrl,
to: "42",
});
const durableText = await synologyChatPlugin.message.send?.text?.({
cfg,
text: "durable adapter text",
to: "42",
});
const durableMedia = await synologyChatPlugin.message.send?.media?.({
cfg,
text: "durable adapter media",
mediaUrl,
to: "42",
});
expect(receivedPayloads).toEqual([
{ text: "native outbound text", user_ids: [42] },
{ file_url: mediaUrl, user_ids: [42] },
{ text: "durable adapter text", user_ids: [42] },
{ file_url: mediaUrl, user_ids: [42] },
]);
expect(durableText).toBeDefined();
expect(durableMedia).toBeDefined();
for (const result of [outboundText, outboundMedia, durableText, durableMedia]) {
expect(result).toMatchObject({
channel: "synology-chat",
messageId: "",
chatId: "42",
receipt: {
platformMessageIds: [],
parts: [],
threadId: "42",
},
});
expect(result?.receipt.primaryPlatformMessageId).toBeUndefined();
}
});
it("chunks long text before the Synology payload limit for every text adapter", async () => {
const receivedPayloads: Array<{ text: string; user_ids?: number[] }> = [];
const port = await listenLoopback((req, res) => {
let formBody = "";
req.setEncoding("utf8");
req.on("data", (chunk: string) => {
formBody += chunk;
});
req.on("end", () => {
const payload = JSON.parse(new URLSearchParams(formBody).get("payload") ?? "{}") as {
text?: string;
user_ids?: number[];
};
receivedPayloads.push({ text: payload.text ?? "", user_ids: payload.user_ids });
const accepted = (payload.text?.length ?? 0) <= 2_000;
res.writeHead(accepted ? 200 : 413, { "Content-Type": "application/json" });
res.end(JSON.stringify({ success: accepted }));
});
});
const incomingUrl = `http://127.0.0.1:${port}/webapi/entry.cgi`;
const cfg = {
channels: {
"synology-chat": {
enabled: true,
token: "loopback-token",
incomingUrl,
},
},
};
const text = "x".repeat(2_001);
await synologyChatPlugin.outbound.sendText({ cfg, text, to: "42" });
await synologyChatPlugin.message.send?.text?.({ cfg, text, to: "42" });
await sendMessage(incomingUrl, text, "42");
expect(receivedPayloads).toHaveLength(6);
expect(receivedPayloads.map(({ text: chunk }) => chunk).join("")).toBe(text + text + text);
expect(receivedPayloads.every(({ text: chunk }) => chunk.length <= 2_000)).toBe(true);
expect(receivedPayloads.every(({ user_ids }) => user_ids?.[0] === 42)).toBe(true);
});
it("rejects unauthenticated webhook sends without fabricating delivery receipts", async () => {
let rejectedRequests = 0;
const port = await listenLoopback((_req, res) => {
rejectedRequests += 1;
res.writeHead(403, { "Content-Type": "application/json" });
res.end(JSON.stringify({ success: false }));
});
const cfg = {
channels: {
"synology-chat": {
enabled: true,
token: "synology-loopback-rejected",
incomingUrl:
`http://127.0.0.1:${port}/webapi/entry.cgi?` +
"api=SYNO.Chat.External&method=chatbot&version=2&token=synology-loopback-rejected",
},
},
};
await expect(
synologyChatPlugin.outbound.sendText({ cfg, text: "rejected", to: "42" }),
).rejects.toThrow("Failed to send message to Synology Chat");
expect(rejectedRequests).toBe(3);
await expect(
synologyChatPlugin.outbound.sendMedia({
cfg,
mediaUrl: "https://example.com/synology-receipt-proof.png",
to: "42",
}),
).rejects.toThrow("Failed to send media to Synology Chat");
expect(rejectedRequests).toBe(4);
});
it("rejects private file URLs before contacting the authenticated webhook", async () => {
let webhookRequests = 0;
const port = await listenLoopback((_req, res) => {
webhookRequests += 1;
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ success: true }));
});
const cfg = {
channels: {
"synology-chat": {
enabled: true,
token: "synology-loopback-proof",
incomingUrl:
`http://127.0.0.1:${port}/webapi/entry.cgi?` +
"api=SYNO.Chat.External&method=chatbot&version=2&token=synology-loopback-proof",
},
},
};
await expect(
synologyChatPlugin.outbound.sendMedia({
cfg,
mediaUrl: `http://127.0.0.1:${port}/private-proof.png`,
to: "42",
}),
).rejects.toThrow("Failed to send media to Synology Chat");
expect(webhookRequests).toBe(0);
});
it("aborts a streamed overflow and returns the stale cached identity", async () => {
let requestCount = 0;
const port = await listenLoopback((_req, res) => {
requestCount += 1;
res.on("error", () => {});
res.writeHead(200, { "Content-Type": "application/json" });
if (requestCount === 1) {
res.end(
JSON.stringify({
success: true,
data: { users: [{ user_id: 17, username: "cached", nickname: "cached-user" }] },
}),
);
return;
}
res.write(Buffer.alloc(USER_LIST_RESPONSE_MAX_BYTES, 0x78));
res.end(Buffer.from("x"));
});
const incomingUrl =
`http://127.0.0.1:${port}/webapi/entry.cgi?` +
"api=SYNO.Chat.External&method=chatbot&version=2";
const now = vi.spyOn(Date, "now");
now.mockReturnValue(1_700_000_000_000);
await expect(
resolveLegacyWebhookNameToChatUserId({
incomingUrl,
mutableWebhookUsername: "cached-user",
}),
).resolves.toBe(17);
now.mockReturnValue(1_700_000_000_000 + 10 * 60 * 1000);
const warnings: string[] = [];
await expect(
resolveLegacyWebhookNameToChatUserId({
incomingUrl,
mutableWebhookUsername: "cached-user",
log: { warn: (...args) => warnings.push(args.map(String).join(" ")) },
}),
).resolves.toBe(17);
expect(requestCount).toBe(2);
expect(warnings).toContain(
`fetchChatUsers: user_list response exceeded ${USER_LIST_RESPONSE_MAX_BYTES} bytes, using cached data`,
);
});
it("bounds a dripping user_list body with a wall-clock deadline", async () => {
let requestCount = 0;
const port = await listenLoopback((_req, res) => {
requestCount += 1;
res.on("error", () => {});
res.writeHead(200, {
"Content-Type": "application/json",
"Transfer-Encoding": "chunked",
});
if (requestCount === 1) {
res.end(
JSON.stringify({
success: true,
data: { users: [{ user_id: 21, username: "cached", nickname: "drip-user" }] },
}),
);
return;
}
// Keep sending bytes so ClientRequest socket-idle alone would never fire.
const dripTimer = setInterval(() => {
if (res.writableEnded || res.destroyed) {
return;
}
res.write("x");
}, 20);
res.on("close", () => clearInterval(dripTimer));
res.write("x");
});
const incomingUrl =
`http://127.0.0.1:${port}/webapi/entry.cgi?` +
"api=SYNO.Chat.External&method=chatbot&version=2";
const now = vi.spyOn(Date, "now");
now.mockReturnValue(1_700_000_100_000);
await expect(
resolveLegacyWebhookNameToChatUserId({
incomingUrl,
mutableWebhookUsername: "drip-user",
}),
).resolves.toBe(21);
now.mockReturnValue(1_700_000_100_000 + 10 * 60 * 1000);
const warnings: string[] = [];
const timeoutMs = 250;
const nativeSetTimeout = globalThis.setTimeout;
const timeoutSpy = vi.spyOn(globalThis, "setTimeout");
timeoutSpy.mockImplementationOnce(((
callback: (...args: unknown[]) => void,
_delay?: number,
...args: unknown[]
) => nativeSetTimeout(callback, timeoutMs, ...args)) as typeof setTimeout);
const startedAt = performance.now();
await expect(
resolveLegacyWebhookNameToChatUserId({
incomingUrl,
mutableWebhookUsername: "drip-user",
log: { warn: (...args) => warnings.push(args.map(String).join(" ")) },
}),
).resolves.toBe(21);
const elapsedMs = performance.now() - startedAt;
expect(requestCount).toBe(2);
expect(timeoutSpy).toHaveBeenCalledWith(expect.any(Function), 15_000);
expect(warnings).toContain("fetchChatUsers: request timed out, using cached data");
expect(elapsedMs).toBeGreaterThanOrEqual(timeoutMs - 50);
expect(elapsedMs).toBeLessThan(timeoutMs + 1_500);
});
it("bounds a dripping chatbot response with a wall-clock deadline", async () => {
let requestCount = 0;
const port = await listenLoopback((_req, res) => {
requestCount += 1;
res.on("error", () => {});
res.writeHead(200, {
"Content-Type": "application/json",
"Transfer-Encoding": "chunked",
});
const dripTimer = setInterval(() => {
if (!res.writableEnded && !res.destroyed) {
res.write("x");
}
}, 20);
res.on("close", () => clearInterval(dripTimer));
res.write("x");
});
const incomingUrl = `http://127.0.0.1:${port}/webapi/entry.cgi`;
const timeoutMs = 250;
const nativeSetTimeout = globalThis.setTimeout;
const timeoutSpy = vi.spyOn(globalThis, "setTimeout");
timeoutSpy.mockImplementation(((
callback: (...args: unknown[]) => void,
delay?: number,
...args: unknown[]
) =>
nativeSetTimeout(
callback,
delay === 30_000 ? timeoutMs : delay,
...args,
)) as typeof setTimeout);
const startedAt = performance.now();
await expect(sendMessage(incomingUrl, "hello")).resolves.toBe(false);
const elapsedMs = performance.now() - startedAt;
expect(requestCount).toBe(3);
expect(timeoutSpy).toHaveBeenCalledWith(expect.any(Function), 30_000);
expect(elapsedMs).toBeGreaterThanOrEqual(timeoutMs * 3 - 100);
expect(elapsedMs).toBeLessThan(3_500);
});
});