Files
openclaw/extensions/synology-chat/src/client.test.ts
Pavan Kumar Gondhi d5e89de906 fix(synology-chat): deliver attachments without forwarding source URLs [AI] (#119941)
* fix(synology-chat): host outbound attachments

* fix(synology-chat): isolate hosted media routes

* fix(synology-chat): harden hosted media limits

* fix(synology-chat): inspect hosted media content

* fix(synology-chat): reject ambiguous media routes

* fix(synology-chat): retain indeterminate media capabilities

* fix(synology-chat): mask public callback URLs

* fix(synology-chat): align callback sensitivity metadata

* fix(synology-chat): reserve media capability query keys

* fix(synology-chat): release rejected staged media

* fix(synology-chat): preserve ambiguous media handoffs

* fix(synology-chat): scan complete active media preamble

* fix(synology-chat): report validated attachment readiness

* fix(synology-chat): bound public media capability probes

* fix(synology-chat): bound hosted media responses

* test(synology-chat): model response headers read-only

* fix(synology-chat): bound hosted media reads

* fix(synology-chat): bound hosted media delivery

* fix(synology-chat): retain media through active serves

* fix(plugin-sdk): lease hosted media readers atomically

* fix(synology-chat): close hosted media review gaps

* test(synology-chat): clean up hosted media state

* fix(system-agent): canonicalize sensitive config paths

* fix(system-agent): honor runtime config sensitivity hints

* fix(config): inherit sensitive metadata

* fix(synology-chat): close latest review findings

* test(system-agent): keep config recovery checks lint-clean

* fix(system-agent): redact structured config secrets

* fix(synology-chat): harden active-content sniffing

* fix(security): close hosted media review gaps

* fix(security): close remaining hosted media review findings

* fix(system-agent): narrow dynamic owner ids safely

* fix(system-agent): narrow dynamic channel ids safely

* test(channels): isolate hosted media proofs

* fix(security): close config and hosted media review gaps

* test(plugin-sdk): split outbound media retention coverage

* test(plugin-sdk): isolate capacity store fixtures

* test(system-agent): assert config secrecy invariant
2026-08-14 17:05:20 -05:00

680 lines
24 KiB
TypeScript

// Synology Chat tests cover client plugin behavior.
import { EventEmitter } from "node:events";
import type { ClientRequest, IncomingMessage, RequestOptions } from "node:http";
import { PassThrough } from "node:stream";
import { coerceErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest";
import type { SynologyHostedMediaUrl } from "./outbound-media.js";
// Mock http and https modules before importing the client
vi.mock("node:https", async () => {
const actual = await vi.importActual<typeof import("node:https")>("node:https");
const httpsRequest = vi.fn();
const httpsGet = vi.fn();
const httpsModule = { ...actual, request: httpsRequest, get: httpsGet };
return { ...actual, default: httpsModule, request: httpsRequest, get: httpsGet };
});
vi.mock("node:http", async () => {
const actual = await vi.importActual<typeof import("node:http")>("node:http");
const httpRequest = vi.fn();
const httpGet = vi.fn();
const httpModule = { ...actual, request: httpRequest, get: httpGet };
return { ...actual, default: httpModule, request: httpRequest, get: httpGet };
});
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
formatErrorMessage: coerceErrorMessage,
}));
const https = await import("node:https");
let fakeNowMs = 1_700_000_000_000;
let sendMessage: typeof import("./client.js").sendMessage;
let sendHostedFileUrl: typeof import("./client.js").sendHostedFileUrl;
let resolveLegacyWebhookNameToChatUserId: typeof import("./client.js").resolveLegacyWebhookNameToChatUserId;
type RequestCallback = (res: IncomingMessage) => void;
type MockRequestHandler = (
url: string | URL,
options: RequestOptions,
callback?: RequestCallback,
) => ClientRequest;
type MockHttpCall = [
string | URL,
RequestOptions & { rejectUnauthorized?: boolean },
RequestCallback?,
];
type MockResponse = IncomingMessage & PassThrough;
function firstHttpsRequestCall(label = "Synology Chat HTTPS request"): MockHttpCall {
const call = vi.mocked(https.request).mock.calls[0];
if (!call) {
throw new Error(`expected ${label}`);
}
return call as MockHttpCall;
}
function firstHttpsGetCall(label = "Synology Chat HTTPS get"): MockHttpCall {
const call = vi.mocked(https.get).mock.calls[0];
if (!call) {
throw new Error(`expected ${label}`);
}
return call as MockHttpCall;
}
function createMockResponseEmitter(statusCode: number): MockResponse {
const res = new PassThrough() as PassThrough & Partial<IncomingMessage>;
res.statusCode = statusCode;
return res as unknown as MockResponse;
}
function createMockRequestEmitter(): ClientRequest {
const req = new EventEmitter() as Partial<ClientRequest>;
req.write = vi.fn() as ClientRequest["write"];
req.end = vi.fn() as ClientRequest["end"];
req.destroy = vi.fn() as ClientRequest["destroy"];
return req as unknown as ClientRequest;
}
async function settleTimers<T>(promise: Promise<T>): Promise<T> {
await Promise.resolve();
await vi.runAllTimersAsync();
return promise;
}
function mockResponse(statusCode: number, body: string) {
const httpsRequest = vi.mocked(https.request);
httpsRequest.mockImplementation(((...args) => {
const callback = args[2];
const res = createMockResponseEmitter(statusCode);
process.nextTick(() => {
callback?.(res);
res.end(body);
});
return createMockRequestEmitter();
}) as MockRequestHandler);
}
function mockSuccessResponse() {
mockResponse(200, '{"success":true}');
}
function mockFailureResponse(statusCode = 500) {
mockResponse(statusCode, "error");
}
function mockRequestErrorOnce(error: Error) {
vi.mocked(https.request).mockImplementationOnce((() => {
const req = createMockRequestEmitter();
process.nextTick(() => req.emit("error", error));
return req;
}) as MockRequestHandler);
}
function installFakeTimerHarness() {
beforeAll(async () => {
({ sendMessage, sendHostedFileUrl, resolveLegacyWebhookNameToChatUserId } =
await import("./client.js"));
});
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
fakeNowMs += 10_000;
vi.setSystemTime(fakeNowMs);
});
afterEach(() => {
vi.useRealTimers();
});
}
function hostedUrl(value: string): SynologyHostedMediaUrl {
return value as SynologyHostedMediaUrl;
}
const tlsVerificationDefaultCases: Array<{ name: string; invoke: () => Promise<unknown> }> = [
{
name: "sendMessage",
invoke: () => sendMessage("https://nas.example.com/incoming", "Hello"),
},
{
name: "sendHostedFileUrl",
invoke: () =>
sendHostedFileUrl(
"https://nas.example.com/incoming",
hostedUrl("https://gateway.example.com/webhook?__openclaw_synology_media_token_a=t"),
),
},
];
describe("Synology Chat TLS verification defaults", () => {
installFakeTimerHarness();
it.each(tlsVerificationDefaultCases)("$name verifies TLS by default", async ({ invoke }) => {
mockSuccessResponse();
await settleTimers(invoke());
const firstCall = firstHttpsRequestCall();
expect(firstCall[1]?.rejectUnauthorized).toBe(true);
});
});
describe("sendMessage", () => {
installFakeTimerHarness();
it("returns true on successful send", async () => {
mockSuccessResponse();
const result = await settleTimers(sendMessage("https://nas.example.com/incoming", "Hello"));
expect(result).toBe(true);
});
it("returns false on server error without replaying", async () => {
mockFailureResponse(500);
const result = await settleTimers(sendMessage("https://nas.example.com/incoming", "Hello"));
expect(result).toBe(false);
expect(vi.mocked(https.request)).toHaveBeenCalledOnce();
});
it("does not replay a mixed aggregate with an ambiguous transport leaf", async () => {
const mixedError = Object.assign(
new AggregateError([
Object.assign(new Error("connect refused"), { code: "ECONNREFUSED" }),
Object.assign(new Error("connection reset after write"), { code: "ECONNRESET" }),
]),
{ code: "ECONNREFUSED" },
);
mockRequestErrorOnce(mixedError);
const result = await settleTimers(sendMessage("https://nas.example.com/incoming", "Hello"));
expect(result).toBe(false);
expect(vi.mocked(https.request)).toHaveBeenCalledOnce();
});
it("retries when every aggregate transport leaf is pre-connect", async () => {
mockSuccessResponse();
const aggregateError = Object.assign(
new AggregateError([
Object.assign(new Error("connect refused"), { code: "ECONNREFUSED" }),
Object.assign(new Error("host not found"), { code: "ENOTFOUND" }),
]),
{ code: "ECONNREFUSED" },
);
mockRequestErrorOnce(new TypeError("fetch failed", { cause: aggregateError }));
const result = await settleTimers(sendMessage("https://nas.example.com/incoming", "Hello"));
expect(result).toBe(true);
expect(vi.mocked(https.request)).toHaveBeenCalledTimes(2);
});
it.each([
{ name: "Synology error envelope", body: { success: false, error: { code: 105 } } },
{ name: "unrelated malformed response fields", body: { success: false, data: null } },
])("does not replay an HTTP-successful webhook rejection ($name)", async ({ body }) => {
mockResponse(200, JSON.stringify(body));
const result = await settleTimers(sendMessage("https://nas.example.com/incoming", "Hello"));
expect(result).toBe(false);
expect(vi.mocked(https.request)).toHaveBeenCalledOnce();
});
it.each([
{ name: "missing success", body: "{}" },
{ name: "null response", body: "null" },
{ name: "empty response", body: "" },
{ name: "malformed JSON", body: '{"success":false' },
{ name: "plain text", body: "ok" },
{ name: "JSON string", body: '"ok"' },
{ name: "JSON number", body: "0" },
{ name: "JSON boolean", body: "false" },
{ name: "JSON array", body: "[]" },
{ name: "null success", body: '{"success":null}' },
{ name: "string success", body: '{"success":"false"}' },
{ name: "numeric success", body: '{"success":0}' },
{
name: "oversized rejection envelope",
body: JSON.stringify({ success: false, padding: "x".repeat(1 * 1024 * 1024) }),
},
])("preserves HTTP-successful webhook responses with $name", async ({ body }) => {
mockResponse(200, body);
const result = await settleTimers(sendMessage("https://nas.example.com/incoming", "Hello"));
expect(result).toBe(true);
expect(vi.mocked(https.request)).toHaveBeenCalledTimes(1);
});
it("includes user_ids when userId is numeric", async () => {
mockSuccessResponse();
await settleTimers(sendMessage("https://nas.example.com/incoming", "Hello", 42));
expect(vi.mocked(https.request)).toHaveBeenCalled();
const callArgs = firstHttpsRequestCall();
expect(callArgs[0]).toBe("https://nas.example.com/incoming");
});
it("does not coerce partial numeric user ids into recipients", async () => {
mockSuccessResponse();
await settleTimers(sendMessage("https://nas.example.com/incoming", "Hello", "42abc"));
const request = vi.mocked(https.request).mock.results[0]?.value as ClientRequest | undefined;
if (!request) {
throw new Error("expected Synology Chat webhook request");
}
const body = vi.mocked(request["write"]).mock.calls[0]?.[0];
if (typeof body !== "string") {
throw new Error("expected Synology Chat webhook body");
}
const payload = JSON.parse(decodeURIComponent(body.replace(/^payload=/, ""))) as Record<
string,
unknown
>;
expect(payload).toEqual({ text: "Hello" });
});
it("accepts plus-signed numeric user ids", async () => {
mockSuccessResponse();
await settleTimers(sendMessage("https://nas.example.com/incoming", "Hello", "+042"));
const request = vi.mocked(https.request).mock.results[0]?.value as ClientRequest | undefined;
if (!request) {
throw new Error("expected Synology Chat webhook request");
}
const body = vi.mocked(request["write"]).mock.calls[0]?.[0];
if (typeof body !== "string") {
throw new Error("expected Synology Chat webhook body");
}
const payload = JSON.parse(decodeURIComponent(body.replace(/^payload=/, ""))) as Record<
string,
unknown
>;
expect(payload).toEqual({ text: "Hello", user_ids: [42] });
});
it("only disables TLS verification when explicitly requested", async () => {
mockSuccessResponse();
await settleTimers(sendMessage("https://nas.example.com/incoming", "Hello", undefined, true));
const firstCall = firstHttpsRequestCall();
expect(firstCall[1]?.rejectUnauthorized).toBe(false);
});
});
describe("sendHostedFileUrl", () => {
installFakeTimerHarness();
it("returns accepted on success", async () => {
mockSuccessResponse();
const result = await settleTimers(
sendHostedFileUrl(
"https://nas.example.com/incoming",
hostedUrl("https://gateway.example.com/webhook?__openclaw_synology_media_token_a=t"),
),
);
expect(result).toEqual({ status: "accepted" });
});
it("returns indeterminate on an HTTP server failure", async () => {
mockFailureResponse(500);
const result = await settleTimers(
sendHostedFileUrl(
"https://nas.example.com/incoming",
hostedUrl("https://gateway.example.com/webhook?__openclaw_synology_media_token_a=t"),
),
);
expect(result).toEqual({ status: "indeterminate" });
});
it("returns rejected on a definitive HTTP client failure", async () => {
mockFailureResponse(400);
const result = await settleTimers(
sendHostedFileUrl(
"https://nas.example.com/incoming",
hostedUrl("https://gateway.example.com/webhook?__openclaw_synology_media_token_a=t"),
),
);
expect(result).toEqual({ status: "rejected" });
});
it("returns rejected without retrying an HTTP-successful webhook rejection", async () => {
mockResponse(200, JSON.stringify({ success: false, error: { code: 105 } }));
const result = await settleTimers(
sendHostedFileUrl(
"https://nas.example.com/incoming",
hostedUrl("https://gateway.example.com/webhook?__openclaw_synology_media_token_a=t"),
),
);
expect(result).toEqual({ status: "rejected" });
expect(vi.mocked(https.request)).toHaveBeenCalledTimes(1);
});
it("returns indeterminate when the request outcome is lost", async () => {
vi.mocked(https.request).mockImplementation((() => {
const req = createMockRequestEmitter();
process.nextTick(() => req.emit("error", new Error("connection reset")));
return req;
}) as MockRequestHandler);
const result = await settleTimers(
sendHostedFileUrl(
"https://nas.example.com/incoming",
hostedUrl("https://gateway.example.com/webhook?__openclaw_synology_media_token_a=t"),
),
);
expect(result).toEqual({ status: "indeterminate" });
});
it("returns not-dispatched when the transport proves it never connected", async () => {
mockRequestErrorOnce(Object.assign(new Error("host not found"), { code: "ENOTFOUND" }));
const result = await settleTimers(
sendHostedFileUrl(
"https://nas.example.com/incoming",
hostedUrl("https://gateway.example.com/webhook?__openclaw_synology_media_token_a=t"),
),
);
expect(result).toEqual({ status: "not-dispatched" });
});
it("returns not-dispatched when request construction fails synchronously", async () => {
vi.mocked(https.request).mockImplementationOnce(() => {
throw new Error("request construction failed");
});
const result = await settleTimers(
sendHostedFileUrl(
"https://nas.example.com/incoming",
hostedUrl("https://gateway.example.com/webhook?__openclaw_synology_media_token_a=t"),
),
);
expect(result).toEqual({ status: "not-dispatched" });
});
it("returns not-dispatched when the incoming webhook URL is malformed", async () => {
const result = await settleTimers(
sendHostedFileUrl(
"not-a-url",
hostedUrl("https://gateway.example.com/webhook?__openclaw_synology_media_token_a=t"),
),
);
expect(result).toEqual({ status: "not-dispatched" });
expect(vi.mocked(https.request)).not.toHaveBeenCalled();
});
it("respects the shared send interval before posting a file URL", async () => {
mockSuccessResponse();
await settleTimers(sendMessage("https://nas.example.com/incoming", "hello"));
vi.mocked(https.request).mockClear();
const promise = sendHostedFileUrl(
"https://nas.example.com/incoming",
hostedUrl("https://gateway.example.com/webhook?__openclaw_synology_media_token_a=t"),
);
await Promise.resolve();
expect(vi.mocked(https.request)).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(499);
expect(vi.mocked(https.request)).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
await promise;
expect(vi.mocked(https.request)).toHaveBeenCalledTimes(1);
});
it("rejects malformed file URLs before making a request", async () => {
const result = await settleTimers(
sendHostedFileUrl("https://nas.example.com/incoming", hostedUrl("not-a-url")),
);
expect(result).toEqual({ status: "not-dispatched" });
expect(vi.mocked(https.request)).not.toHaveBeenCalled();
});
it("rejects non-HTTPS file URLs before making a request", async () => {
const result = await settleTimers(
sendHostedFileUrl("https://nas.example.com/incoming", hostedUrl("http://example.com/file")),
);
expect(result).toEqual({ status: "not-dispatched" });
expect(vi.mocked(https.request)).not.toHaveBeenCalled();
});
it("rejects hosted URLs with embedded credentials or fragments", async () => {
const credentialedUrl = new URL("https://gateway.example.com/webhook#fragment");
credentialedUrl.username = "fixture-user";
credentialedUrl.password = "fixture-password";
const result = await settleTimers(
sendHostedFileUrl("https://nas.example.com/incoming", hostedUrl(credentialedUrl.toString())),
);
expect(result).toEqual({ status: "not-dispatched" });
expect(vi.mocked(https.request)).not.toHaveBeenCalled();
});
});
// Helper to mock the user_list API response for fetchChatUsers / resolveLegacyWebhookNameToChatUserId
function mockUserListResponse(users: Array<Record<string, unknown>>) {
mockUserListResponseImpl(users, false);
}
function mockUserListResponseOnce(users: Array<Record<string, unknown>>) {
mockUserListResponseImpl(users, true);
}
function mockUserListResponseImpl(users: Array<Record<string, unknown>>, once: boolean) {
const httpsGet = vi.mocked(https.get);
const impl: MockRequestHandler = (_url, _opts, callback) => {
const res = createMockResponseEmitter(200);
process.nextTick(() => {
callback?.(res);
res.end(JSON.stringify({ success: true, data: { users } }));
});
return createMockRequestEmitter();
};
if (once) {
httpsGet.mockImplementationOnce(impl);
return;
}
httpsGet.mockImplementation(impl);
}
describe("resolveLegacyWebhookNameToChatUserId", () => {
const baseUrl =
"https://nas.example.com/webapi/entry.cgi?api=SYNO.Chat.External&method=chatbot&version=2&token=%22test%22";
const baseUrl2 =
"https://nas2.example.com/webapi/entry.cgi?api=SYNO.Chat.External&method=chatbot&version=2&token=%22test-2%22";
beforeAll(async () => {
({ resolveLegacyWebhookNameToChatUserId } = await import("./client.js"));
});
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
// Advance time to invalidate any cached user list from previous tests
fakeNowMs += 10 * 60 * 1000;
vi.setSystemTime(fakeNowMs);
});
afterEach(() => {
vi.useRealTimers();
});
it("resolves user by nickname (webhook username = Chat nickname)", async () => {
mockUserListResponse([
{ user_id: 4, username: "jmn67", nickname: "jmn" },
{ user_id: 7, username: "she67", nickname: "sarah" },
]);
const result = await resolveLegacyWebhookNameToChatUserId({
incomingUrl: baseUrl,
mutableWebhookUsername: "jmn",
});
expect(result).toBe(4);
});
it("preserves UTF-8 when user_list splits a nickname across response chunks", async () => {
const nickname = "猫";
const responseBody = Buffer.from(
JSON.stringify({
success: true,
data: { users: [{ user_id: 4, username: "jmn67", nickname }] },
}),
"utf8",
);
const nicknameOffset = responseBody.indexOf(Buffer.from(nickname, "utf8"));
const splitAt = nicknameOffset + 1;
vi.mocked(https.get).mockImplementation(((_url, _opts, callback) => {
const res = createMockResponseEmitter(200);
process.nextTick(() => {
callback?.(res);
res.write(responseBody.subarray(0, splitAt));
res.end(responseBody.subarray(splitAt));
});
return createMockRequestEmitter();
}) as MockRequestHandler);
const result = await resolveLegacyWebhookNameToChatUserId({
incomingUrl: baseUrl,
mutableWebhookUsername: nickname,
});
expect(result).toBe(4);
});
it("resolves user by username when nickname does not match", async () => {
mockUserListResponse([
{ user_id: 4, username: "jmn67", nickname: "" },
{ user_id: 7, username: "she67", nickname: "sarah" },
]);
// Advance time to invalidate cache
fakeNowMs += 10 * 60 * 1000;
vi.setSystemTime(fakeNowMs);
const result = await resolveLegacyWebhookNameToChatUserId({
incomingUrl: baseUrl,
mutableWebhookUsername: "jmn67",
});
expect(result).toBe(4);
});
it("is case-insensitive", async () => {
mockUserListResponse([{ user_id: 4, username: "JMN67", nickname: "JMN" }]);
fakeNowMs += 10 * 60 * 1000;
vi.setSystemTime(fakeNowMs);
const result = await resolveLegacyWebhookNameToChatUserId({
incomingUrl: baseUrl,
mutableWebhookUsername: "jmn",
});
expect(result).toBe(4);
});
it("returns undefined when user is not found", async () => {
mockUserListResponse([{ user_id: 4, username: "jmn67", nickname: "jmn" }]);
fakeNowMs += 10 * 60 * 1000;
vi.setSystemTime(fakeNowMs);
const result = await resolveLegacyWebhookNameToChatUserId({
incomingUrl: baseUrl,
mutableWebhookUsername: "unknown_user",
});
expect(result).toBeUndefined();
});
it("uses method=user_list instead of method=chatbot in the API URL", async () => {
mockUserListResponse([]);
fakeNowMs += 10 * 60 * 1000;
vi.setSystemTime(fakeNowMs);
await resolveLegacyWebhookNameToChatUserId({
incomingUrl: baseUrl,
mutableWebhookUsername: "anyone",
});
const call = firstHttpsGetCall("Synology Chat user_list request");
expect(String(call[0])).toBe(baseUrl.replace("method=chatbot", "method=user_list"));
expect(call[1]).toEqual({ rejectUnauthorized: true });
expect(typeof call[2]).toBe("function");
});
it("keeps user cache scoped per incoming URL", async () => {
mockUserListResponseOnce([{ user_id: 4, username: "jmn67", nickname: "jmn" }]);
mockUserListResponseOnce([{ user_id: 9, username: "jmn67", nickname: "jmn" }]);
const result1 = await resolveLegacyWebhookNameToChatUserId({
incomingUrl: baseUrl,
mutableWebhookUsername: "jmn",
});
const result2 = await resolveLegacyWebhookNameToChatUserId({
incomingUrl: baseUrl2,
mutableWebhookUsername: "jmn",
});
expect(result1).toBe(4);
expect(result2).toBe(9);
const httpsGet = vi.mocked(https.get);
expect(httpsGet).toHaveBeenCalledTimes(2);
});
});
describe("resolveLegacyWebhookNameToChatUserId user lookup", () => {
installFakeTimerHarness();
it("filters malformed user entries while keeping valid ones", async () => {
mockUserListResponse([
{ user_id: 4, username: "jmn67", nickname: "jmn" },
{ user_id: "bad", username: "broken" },
]);
const userId = await resolveLegacyWebhookNameToChatUserId({
incomingUrl:
"https://nas.example.com/webapi/entry.cgi?api=SYNO.Chat.External&method=chatbot&version=2&token=%22test%22",
mutableWebhookUsername: "jmn",
});
expect(userId).toBe(4);
});
it("falls back when the user_list body exceeds the byte cap", async () => {
const httpsGet = vi.mocked(https.get);
const warns: string[] = [];
// Single oversized Buffer: over 1 MiB cap without multi-write/destroy races.
const oversized = Buffer.alloc(1 * 1024 * 1024 + 1, 0x78);
const overflowUrl =
"https://overflow-nas.example.com/webapi/entry.cgi?api=SYNO.Chat.External&method=chatbot&version=2";
httpsGet.mockImplementation(((_url, _opts, callback) => {
const res = createMockResponseEmitter(200);
process.nextTick(() => {
callback?.(res);
res.end(oversized);
});
return createMockRequestEmitter();
}) as MockRequestHandler);
const userId = await resolveLegacyWebhookNameToChatUserId({
incomingUrl: overflowUrl,
mutableWebhookUsername: "anyone",
log: {
warn: (...args: unknown[]) => {
warns.push(args.map(String).join(" "));
},
},
});
expect(userId).toBeUndefined();
expect(warns.some((line) => line.includes("exceeded"))).toBe(true);
});
it("verifies TLS by default for user_list lookups", async () => {
mockUserListResponse([{ user_id: 4, username: "jmn67", nickname: "jmn" }]);
const freshUrl =
"https://fresh-nas.example.com/webapi/entry.cgi?api=SYNO.Chat.External&method=chatbot&version=2&token=%22fresh%22";
await resolveLegacyWebhookNameToChatUserId({
incomingUrl: freshUrl,
mutableWebhookUsername: "jmn",
});
const firstCall = firstHttpsGetCall();
expect(firstCall[1]?.rejectUnauthorized).toBe(true);
});
});