test: dedupe fixtures and test harness setup

This commit is contained in:
Peter Steinberger
2026-02-23 05:43:30 +00:00
parent 8af19ddc5b
commit 1c753ea786
75 changed files with 1886 additions and 2136 deletions

View File

@@ -47,6 +47,22 @@ describe("bluebubblesMessageActions", () => {
const handleAction = bluebubblesMessageActions.handleAction!;
const callHandleAction = (ctx: Omit<Parameters<typeof handleAction>[0], "channel">) =>
handleAction({ channel: "bluebubbles", ...ctx });
const blueBubblesConfig = (): OpenClawConfig => ({
channels: {
bluebubbles: {
serverUrl: "http://localhost:1234",
password: "test-password",
},
},
});
const runReactAction = async (params: Record<string, unknown>) => {
return await callHandleAction({
action: "react",
params,
cfg: blueBubblesConfig(),
accountId: null,
});
};
beforeEach(() => {
vi.clearAllMocks();
@@ -285,23 +301,10 @@ describe("bluebubblesMessageActions", () => {
it("sends reaction successfully with chatGuid", async () => {
const { sendBlueBubblesReaction } = await import("./reactions.js");
const cfg: OpenClawConfig = {
channels: {
bluebubbles: {
serverUrl: "http://localhost:1234",
password: "test-password",
},
},
};
const result = await callHandleAction({
action: "react",
params: {
emoji: "❤️",
messageId: "msg-123",
chatGuid: "iMessage;-;+15551234567",
},
cfg,
accountId: null,
const result = await runReactAction({
emoji: "❤️",
messageId: "msg-123",
chatGuid: "iMessage;-;+15551234567",
});
expect(sendBlueBubblesReaction).toHaveBeenCalledWith(
@@ -320,24 +323,11 @@ describe("bluebubblesMessageActions", () => {
it("sends reaction removal successfully", async () => {
const { sendBlueBubblesReaction } = await import("./reactions.js");
const cfg: OpenClawConfig = {
channels: {
bluebubbles: {
serverUrl: "http://localhost:1234",
password: "test-password",
},
},
};
const result = await callHandleAction({
action: "react",
params: {
emoji: "❤️",
messageId: "msg-123",
chatGuid: "iMessage;-;+15551234567",
remove: true,
},
cfg,
accountId: null,
const result = await runReactAction({
emoji: "❤️",
messageId: "msg-123",
chatGuid: "iMessage;-;+15551234567",
remove: true,
});
expect(sendBlueBubblesReaction).toHaveBeenCalledWith(

View File

@@ -64,6 +64,24 @@ describe("downloadBlueBubblesAttachment", () => {
setBlueBubblesRuntime(runtimeStub);
});
async function expectAttachmentTooLarge(params: { bufferBytes: number; maxBytes?: number }) {
const largeBuffer = new Uint8Array(params.bufferBytes);
mockFetch.mockResolvedValueOnce({
ok: true,
headers: new Headers(),
arrayBuffer: () => Promise.resolve(largeBuffer.buffer),
});
const attachment: BlueBubblesAttachment = { guid: "att-large" };
await expect(
downloadBlueBubblesAttachment(attachment, {
serverUrl: "http://localhost:1234",
password: "test",
...(params.maxBytes === undefined ? {} : { maxBytes: params.maxBytes }),
}),
).rejects.toThrow("too large");
}
it("throws when guid is missing", async () => {
const attachment: BlueBubblesAttachment = {};
await expect(
@@ -175,38 +193,14 @@ describe("downloadBlueBubblesAttachment", () => {
});
it("throws when attachment exceeds max bytes", async () => {
const largeBuffer = new Uint8Array(10 * 1024 * 1024);
mockFetch.mockResolvedValueOnce({
ok: true,
headers: new Headers(),
arrayBuffer: () => Promise.resolve(largeBuffer.buffer),
await expectAttachmentTooLarge({
bufferBytes: 10 * 1024 * 1024,
maxBytes: 5 * 1024 * 1024,
});
const attachment: BlueBubblesAttachment = { guid: "att-large" };
await expect(
downloadBlueBubblesAttachment(attachment, {
serverUrl: "http://localhost:1234",
password: "test",
maxBytes: 5 * 1024 * 1024,
}),
).rejects.toThrow("too large");
});
it("uses default max bytes when not specified", async () => {
const largeBuffer = new Uint8Array(9 * 1024 * 1024);
mockFetch.mockResolvedValueOnce({
ok: true,
headers: new Headers(),
arrayBuffer: () => Promise.resolve(largeBuffer.buffer),
});
const attachment: BlueBubblesAttachment = { guid: "att-large" };
await expect(
downloadBlueBubblesAttachment(attachment, {
serverUrl: "http://localhost:1234",
password: "test",
}),
).rejects.toThrow("too large");
await expectAttachmentTooLarge({ bufferBytes: 9 * 1024 * 1024 });
});
it("uses attachment mimeType as fallback when response has no content-type", async () => {

View File

@@ -22,6 +22,44 @@ installBlueBubblesFetchTestHooks({
});
describe("chat", () => {
function mockOkTextResponse() {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
}
async function expectCalledUrlIncludesPassword(params: {
password: string;
invoke: () => Promise<void>;
}) {
mockOkTextResponse();
await params.invoke();
const calledUrl = mockFetch.mock.calls[0][0] as string;
expect(calledUrl).toContain(`password=${params.password}`);
}
async function expectCalledUrlUsesConfigCredentials(params: {
serverHost: string;
password: string;
invoke: (cfg: {
channels: { bluebubbles: { serverUrl: string; password: string } };
}) => Promise<void>;
}) {
mockOkTextResponse();
await params.invoke({
channels: {
bluebubbles: {
serverUrl: `http://${params.serverHost}`,
password: params.password,
},
},
});
const calledUrl = mockFetch.mock.calls[0][0] as string;
expect(calledUrl).toContain(params.serverHost);
expect(calledUrl).toContain(`password=${params.password}`);
}
describe("markBlueBubblesChatRead", () => {
it("does nothing when chatGuid is empty or whitespace", async () => {
for (const chatGuid of ["", " "]) {
@@ -73,18 +111,14 @@ describe("chat", () => {
});
it("includes password in URL query", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await markBlueBubblesChatRead("chat-123", {
serverUrl: "http://localhost:1234",
await expectCalledUrlIncludesPassword({
password: "my-secret",
invoke: () =>
markBlueBubblesChatRead("chat-123", {
serverUrl: "http://localhost:1234",
password: "my-secret",
}),
});
const calledUrl = mockFetch.mock.calls[0][0] as string;
expect(calledUrl).toContain("password=my-secret");
});
it("throws on non-ok response", async () => {
@@ -119,25 +153,14 @@ describe("chat", () => {
});
it("resolves credentials from config", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
await expectCalledUrlUsesConfigCredentials({
serverHost: "config-server:9999",
password: "config-pass",
invoke: (cfg) =>
markBlueBubblesChatRead("chat-123", {
cfg,
}),
});
await markBlueBubblesChatRead("chat-123", {
cfg: {
channels: {
bluebubbles: {
serverUrl: "http://config-server:9999",
password: "config-pass",
},
},
},
});
const calledUrl = mockFetch.mock.calls[0][0] as string;
expect(calledUrl).toContain("config-server:9999");
expect(calledUrl).toContain("password=config-pass");
});
});
@@ -536,18 +559,14 @@ describe("chat", () => {
});
it("includes password in URL query", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await setGroupIconBlueBubbles("chat-123", new Uint8Array([1, 2, 3]), "icon.png", {
serverUrl: "http://localhost:1234",
await expectCalledUrlIncludesPassword({
password: "my-secret",
invoke: () =>
setGroupIconBlueBubbles("chat-123", new Uint8Array([1, 2, 3]), "icon.png", {
serverUrl: "http://localhost:1234",
password: "my-secret",
}),
});
const calledUrl = mockFetch.mock.calls[0][0] as string;
expect(calledUrl).toContain("password=my-secret");
});
it("throws on non-ok response", async () => {
@@ -582,25 +601,14 @@ describe("chat", () => {
});
it("resolves credentials from config", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
await expectCalledUrlUsesConfigCredentials({
serverHost: "config-server:9999",
password: "config-pass",
invoke: (cfg) =>
setGroupIconBlueBubbles("chat-123", new Uint8Array([1]), "icon.png", {
cfg,
}),
});
await setGroupIconBlueBubbles("chat-123", new Uint8Array([1]), "icon.png", {
cfg: {
channels: {
bluebubbles: {
serverUrl: "http://config-server:9999",
password: "config-pass",
},
},
},
});
const calledUrl = mockFetch.mock.calls[0][0] as string;
expect(calledUrl).toContain("config-server:9999");
expect(calledUrl).toContain("password=config-pass");
});
it("includes filename in multipart body", async () => {

View File

@@ -19,6 +19,27 @@ describe("reactions", () => {
});
describe("sendBlueBubblesReaction", () => {
async function expectRemovedReaction(emoji: string) {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await sendBlueBubblesReaction({
chatGuid: "chat-123",
messageGuid: "msg-123",
emoji,
remove: true,
opts: {
serverUrl: "http://localhost:1234",
password: "test",
},
});
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.reaction).toBe("-love");
}
it("throws when chatGuid is empty", async () => {
await expect(
sendBlueBubblesReaction({
@@ -208,45 +229,11 @@ describe("reactions", () => {
});
it("sends reaction removal with dash prefix", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await sendBlueBubblesReaction({
chatGuid: "chat-123",
messageGuid: "msg-123",
emoji: "love",
remove: true,
opts: {
serverUrl: "http://localhost:1234",
password: "test",
},
});
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.reaction).toBe("-love");
await expectRemovedReaction("love");
});
it("strips leading dash from emoji when remove flag is set", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
await sendBlueBubblesReaction({
chatGuid: "chat-123",
messageGuid: "msg-123",
emoji: "-love",
remove: true,
opts: {
serverUrl: "http://localhost:1234",
password: "test",
},
});
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.reaction).toBe("-love");
await expectRemovedReaction("-love");
});
it("uses custom partIndex when provided", async () => {

View File

@@ -44,6 +44,23 @@ function mockSendResponse(body: unknown) {
});
}
function mockNewChatSendResponse(guid: string) {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ data: [] }),
})
.mockResolvedValueOnce({
ok: true,
text: () =>
Promise.resolve(
JSON.stringify({
data: { guid },
}),
),
});
}
describe("send", () => {
describe("resolveChatGuidForTarget", () => {
const resolveHandleTargetGuid = async (data: Array<Record<string, unknown>>) => {
@@ -453,20 +470,7 @@ describe("send", () => {
});
it("strips markdown when creating a new chat", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ data: [] }),
})
.mockResolvedValueOnce({
ok: true,
text: () =>
Promise.resolve(
JSON.stringify({
data: { guid: "new-msg-stripped" },
}),
),
});
mockNewChatSendResponse("new-msg-stripped");
const result = await sendMessageBlueBubbles("+15550009999", "**Welcome** to the _chat_!", {
serverUrl: "http://localhost:1234",
@@ -483,20 +487,7 @@ describe("send", () => {
});
it("creates a new chat when handle target is missing", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ data: [] }),
})
.mockResolvedValueOnce({
ok: true,
text: () =>
Promise.resolve(
JSON.stringify({
data: { guid: "new-msg-guid" },
}),
),
});
mockNewChatSendResponse("new-msg-guid");
const result = await sendMessageBlueBubbles("+15550009999", "Hello new chat", {
serverUrl: "http://localhost:1234",

View File

@@ -1,10 +1,6 @@
import type {
OpenClawConfig,
PluginRuntime,
ResolvedLineAccount,
RuntimeEnv,
} from "openclaw/plugin-sdk";
import type { OpenClawConfig, PluginRuntime, ResolvedLineAccount } from "openclaw/plugin-sdk";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createRuntimeEnv } from "../../test-utils/runtime-env.js";
import { linePlugin } from "./channel.js";
import { setLineRuntime } from "./runtime.js";
@@ -47,16 +43,6 @@ function createRuntime(): { runtime: PluginRuntime; mocks: LineRuntimeMocks } {
return { runtime, mocks: { writeConfigFile, resolveLineAccount } };
}
function createRuntimeEnv(): RuntimeEnv {
return {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn((code: number): never => {
throw new Error(`exit ${code}`);
}),
};
}
function resolveAccount(
resolveLineAccount: LineRuntimeMocks["resolveLineAccount"],
cfg: OpenClawConfig,

View File

@@ -4,9 +4,9 @@ import type {
OpenClawConfig,
PluginRuntime,
ResolvedLineAccount,
RuntimeEnv,
} from "openclaw/plugin-sdk";
import { describe, expect, it, vi } from "vitest";
import { createRuntimeEnv } from "../../test-utils/runtime-env.js";
import { linePlugin } from "./channel.js";
import { setLineRuntime } from "./runtime.js";
@@ -33,20 +33,10 @@ function createRuntime() {
return { runtime, probeLineBot, monitorLineProvider };
}
function createRuntimeEnv(): RuntimeEnv {
return {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn((code: number): never => {
throw new Error(`exit ${code}`);
}),
};
}
function createStartAccountCtx(params: {
token: string;
secret: string;
runtime: RuntimeEnv;
runtime: ReturnType<typeof createRuntimeEnv>;
}): ChannelGatewayContext<ResolvedLineAccount> {
const snapshot: ChannelAccountSnapshot = {
accountId: "default",

View File

@@ -5,6 +5,12 @@ import path from "node:path";
import { PassThrough } from "node:stream";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawPluginApi, OpenClawPluginToolContext } from "../../../src/plugins/types.js";
import {
createWindowsCmdShimFixture,
restorePlatformPathEnv,
setProcessPlatform,
snapshotPlatformPathEnv,
} from "./test-helpers.js";
const spawnState = vi.hoisted(() => ({
queue: [] as Array<{ stdout: string; stderr?: string; exitCode?: number }>,
@@ -57,20 +63,9 @@ function fakeCtx(overrides: Partial<OpenClawPluginToolContext> = {}): OpenClawPl
};
}
function setProcessPlatform(platform: NodeJS.Platform) {
Object.defineProperty(process, "platform", {
value: platform,
configurable: true,
});
}
describe("lobster plugin tool", () => {
let tempDir = "";
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
const originalPath = process.env.PATH;
const originalPathAlt = process.env.Path;
const originalPathExt = process.env.PATHEXT;
const originalPathExtAlt = process.env.Pathext;
const originalProcessState = snapshotPlatformPathEnv();
beforeAll(async () => {
({ createLobsterTool } = await import("./lobster-tool.js"));
@@ -79,29 +74,7 @@ describe("lobster plugin tool", () => {
});
afterEach(() => {
if (originalPlatform) {
Object.defineProperty(process, "platform", originalPlatform);
}
if (originalPath === undefined) {
delete process.env.PATH;
} else {
process.env.PATH = originalPath;
}
if (originalPathAlt === undefined) {
delete process.env.Path;
} else {
process.env.Path = originalPathAlt;
}
if (originalPathExt === undefined) {
delete process.env.PATHEXT;
} else {
process.env.PATHEXT = originalPathExt;
}
if (originalPathExtAlt === undefined) {
delete process.env.Pathext;
} else {
process.env.Pathext = originalPathExtAlt;
}
restorePlatformPathEnv(originalProcessState);
});
afterAll(async () => {
@@ -156,17 +129,6 @@ describe("lobster plugin tool", () => {
});
};
const createWindowsShimFixture = async (params: {
shimPath: string;
scriptPath: string;
scriptToken: string;
}) => {
await fs.mkdir(path.dirname(params.scriptPath), { recursive: true });
await fs.mkdir(path.dirname(params.shimPath), { recursive: true });
await fs.writeFile(params.scriptPath, "module.exports = {};\n", "utf8");
await fs.writeFile(params.shimPath, `@echo off\r\n"${params.scriptToken}" %*\r\n`, "utf8");
};
it("runs lobster and returns parsed envelope in details", async () => {
spawnState.queue.push({
stdout: JSON.stringify({
@@ -281,10 +243,10 @@ describe("lobster plugin tool", () => {
setProcessPlatform("win32");
const shimScriptPath = path.join(tempDir, "shim-dist", "lobster-cli.cjs");
const shimPath = path.join(tempDir, "shim-bin", "lobster.cmd");
await createWindowsShimFixture({
await createWindowsCmdShimFixture({
shimPath,
scriptPath: shimScriptPath,
scriptToken: "%dp0%\\..\\shim-dist\\lobster-cli.cjs",
shimLine: `"%dp0%\\..\\shim-dist\\lobster-cli.cjs" %*`,
});
process.env.PATHEXT = ".CMD;.EXE";
process.env.PATH = `${path.dirname(shimPath)};${process.env.PATH ?? ""}`;

View File

@@ -0,0 +1,56 @@
import fs from "node:fs/promises";
import path from "node:path";
type PathEnvKey = "PATH" | "Path" | "PATHEXT" | "Pathext";
const PATH_ENV_KEYS = ["PATH", "Path", "PATHEXT", "Pathext"] as const;
export type PlatformPathEnvSnapshot = {
platformDescriptor: PropertyDescriptor | undefined;
env: Record<PathEnvKey, string | undefined>;
};
export function setProcessPlatform(platform: NodeJS.Platform): void {
Object.defineProperty(process, "platform", {
value: platform,
configurable: true,
});
}
export function snapshotPlatformPathEnv(): PlatformPathEnvSnapshot {
return {
platformDescriptor: Object.getOwnPropertyDescriptor(process, "platform"),
env: {
PATH: process.env.PATH,
Path: process.env.Path,
PATHEXT: process.env.PATHEXT,
Pathext: process.env.Pathext,
},
};
}
export function restorePlatformPathEnv(snapshot: PlatformPathEnvSnapshot): void {
if (snapshot.platformDescriptor) {
Object.defineProperty(process, "platform", snapshot.platformDescriptor);
}
for (const key of PATH_ENV_KEYS) {
const value = snapshot.env[key];
if (value === undefined) {
delete process.env[key];
continue;
}
process.env[key] = value;
}
}
export async function createWindowsCmdShimFixture(params: {
shimPath: string;
scriptPath: string;
shimLine: string;
}): Promise<void> {
await fs.mkdir(path.dirname(params.scriptPath), { recursive: true });
await fs.mkdir(path.dirname(params.shimPath), { recursive: true });
await fs.writeFile(params.scriptPath, "module.exports = {};\n", "utf8");
await fs.writeFile(params.shimPath, `@echo off\r\n${params.shimLine}\r\n`, "utf8");
}

View File

@@ -2,22 +2,17 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
createWindowsCmdShimFixture,
restorePlatformPathEnv,
setProcessPlatform,
snapshotPlatformPathEnv,
} from "./test-helpers.js";
import { resolveWindowsLobsterSpawn } from "./windows-spawn.js";
function setProcessPlatform(platform: NodeJS.Platform) {
Object.defineProperty(process, "platform", {
value: platform,
configurable: true,
});
}
describe("resolveWindowsLobsterSpawn", () => {
let tempDir = "";
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
const originalPath = process.env.PATH;
const originalPathAlt = process.env.Path;
const originalPathExt = process.env.PATHEXT;
const originalPathExtAlt = process.env.Pathext;
const originalProcessState = snapshotPlatformPathEnv();
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lobster-win-spawn-"));
@@ -25,29 +20,7 @@ describe("resolveWindowsLobsterSpawn", () => {
});
afterEach(async () => {
if (originalPlatform) {
Object.defineProperty(process, "platform", originalPlatform);
}
if (originalPath === undefined) {
delete process.env.PATH;
} else {
process.env.PATH = originalPath;
}
if (originalPathAlt === undefined) {
delete process.env.Path;
} else {
process.env.Path = originalPathAlt;
}
if (originalPathExt === undefined) {
delete process.env.PATHEXT;
} else {
process.env.PATHEXT = originalPathExt;
}
if (originalPathExtAlt === undefined) {
delete process.env.Pathext;
} else {
process.env.Pathext = originalPathExtAlt;
}
restorePlatformPathEnv(originalProcessState);
if (tempDir) {
await fs.rm(tempDir, { recursive: true, force: true });
tempDir = "";
@@ -57,14 +30,11 @@ describe("resolveWindowsLobsterSpawn", () => {
it("unwraps cmd shim with %dp0% token", async () => {
const scriptPath = path.join(tempDir, "shim-dist", "lobster-cli.cjs");
const shimPath = path.join(tempDir, "shim", "lobster.cmd");
await fs.mkdir(path.dirname(scriptPath), { recursive: true });
await fs.mkdir(path.dirname(shimPath), { recursive: true });
await fs.writeFile(scriptPath, "module.exports = {};\n", "utf8");
await fs.writeFile(
await createWindowsCmdShimFixture({
shimPath,
`@echo off\r\n"%dp0%\\..\\shim-dist\\lobster-cli.cjs" %*\r\n`,
"utf8",
);
scriptPath,
shimLine: `"%dp0%\\..\\shim-dist\\lobster-cli.cjs" %*`,
});
const target = resolveWindowsLobsterSpawn(shimPath, ["run", "noop"], process.env);
expect(target.command).toBe(process.execPath);
@@ -75,14 +45,11 @@ describe("resolveWindowsLobsterSpawn", () => {
it("unwraps cmd shim with %~dp0% token", async () => {
const scriptPath = path.join(tempDir, "shim-dist", "lobster-cli.cjs");
const shimPath = path.join(tempDir, "shim", "lobster.cmd");
await fs.mkdir(path.dirname(scriptPath), { recursive: true });
await fs.mkdir(path.dirname(shimPath), { recursive: true });
await fs.writeFile(scriptPath, "module.exports = {};\n", "utf8");
await fs.writeFile(
await createWindowsCmdShimFixture({
shimPath,
`@echo off\r\n"%~dp0%\\..\\shim-dist\\lobster-cli.cjs" %*\r\n`,
"utf8",
);
scriptPath,
shimLine: `"%~dp0%\\..\\shim-dist\\lobster-cli.cjs" %*`,
});
const target = resolveWindowsLobsterSpawn(shimPath, ["run", "noop"], process.env);
expect(target.command).toBe(process.execPath);

View File

@@ -1,9 +1,5 @@
import type { PluginRuntime } from "openclaw/plugin-sdk";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { downloadMSTeamsAttachments } from "./attachments/download.js";
import { buildMSTeamsGraphMessageUrls, downloadMSTeamsGraphMedia } from "./attachments/graph.js";
import { buildMSTeamsAttachmentPlaceholder } from "./attachments/html.js";
import { buildMSTeamsMediaPayload } from "./attachments/payload.js";
import { setMSTeamsRuntime } from "./runtime.js";
/** Mock DNS resolver that always returns a public IP (for anti-SSRF validation in tests). */
@@ -52,7 +48,58 @@ const runtimeStub = {
},
} as unknown as PluginRuntime;
type AttachmentsModule = typeof import("./attachments.js");
type DownloadAttachmentsParams = Parameters<AttachmentsModule["downloadMSTeamsAttachments"]>[0];
type DownloadGraphMediaParams = Parameters<AttachmentsModule["downloadMSTeamsGraphMedia"]>[0];
const DEFAULT_MESSAGE_URL = "https://graph.microsoft.com/v1.0/chats/19%3Achat/messages/123";
const DEFAULT_MAX_BYTES = 1024 * 1024;
const DEFAULT_ALLOW_HOSTS = ["x"];
const createOkFetchMock = (contentType: string, payload = "png") =>
vi.fn(async () => {
return new Response(Buffer.from(payload), {
status: 200,
headers: { "content-type": contentType },
});
});
const buildDownloadParams = (
attachments: DownloadAttachmentsParams["attachments"],
overrides: Partial<
Omit<DownloadAttachmentsParams, "attachments" | "maxBytes" | "allowHosts" | "resolveFn">
> &
Pick<DownloadAttachmentsParams, "allowHosts" | "resolveFn"> = {},
): DownloadAttachmentsParams => {
return {
attachments,
maxBytes: DEFAULT_MAX_BYTES,
allowHosts: DEFAULT_ALLOW_HOSTS,
resolveFn: publicResolveFn,
...overrides,
};
};
const buildDownloadGraphParams = (
fetchFn: typeof fetch,
overrides: Partial<
Omit<DownloadGraphMediaParams, "messageUrl" | "tokenProvider" | "maxBytes">
> = {},
): DownloadGraphMediaParams => {
return {
messageUrl: DEFAULT_MESSAGE_URL,
tokenProvider: { getAccessToken: vi.fn(async () => "token") },
maxBytes: DEFAULT_MAX_BYTES,
fetchFn,
...overrides,
};
};
describe("msteams attachments", () => {
const load = async () => {
return await import("./attachments.js");
};
beforeEach(() => {
detectMimeMock.mockClear();
saveMediaBufferMock.mockClear();
@@ -62,11 +109,13 @@ describe("msteams attachments", () => {
describe("buildMSTeamsAttachmentPlaceholder", () => {
it("returns empty string when no attachments", async () => {
const { buildMSTeamsAttachmentPlaceholder } = await load();
expect(buildMSTeamsAttachmentPlaceholder(undefined)).toBe("");
expect(buildMSTeamsAttachmentPlaceholder([])).toBe("");
});
it("returns image placeholder for image attachments", async () => {
const { buildMSTeamsAttachmentPlaceholder } = await load();
expect(
buildMSTeamsAttachmentPlaceholder([
{ contentType: "image/png", contentUrl: "https://x/img.png" },
@@ -81,6 +130,7 @@ describe("msteams attachments", () => {
});
it("treats Teams file.download.info image attachments as images", async () => {
const { buildMSTeamsAttachmentPlaceholder } = await load();
expect(
buildMSTeamsAttachmentPlaceholder([
{
@@ -92,6 +142,7 @@ describe("msteams attachments", () => {
});
it("returns document placeholder for non-image attachments", async () => {
const { buildMSTeamsAttachmentPlaceholder } = await load();
expect(
buildMSTeamsAttachmentPlaceholder([
{ contentType: "application/pdf", contentUrl: "https://x/x.pdf" },
@@ -106,6 +157,7 @@ describe("msteams attachments", () => {
});
it("counts inline images in text/html attachments", async () => {
const { buildMSTeamsAttachmentPlaceholder } = await load();
expect(
buildMSTeamsAttachmentPlaceholder([
{
@@ -127,20 +179,13 @@ describe("msteams attachments", () => {
describe("downloadMSTeamsAttachments", () => {
it("downloads and stores image contentUrl attachments", async () => {
const fetchMock = vi.fn(async () => {
return new Response(Buffer.from("png"), {
status: 200,
headers: { "content-type": "image/png" },
});
});
const media = await downloadMSTeamsAttachments({
attachments: [{ contentType: "image/png", contentUrl: "https://x/img" }],
maxBytes: 1024 * 1024,
allowHosts: ["x"],
fetchFn: fetchMock as unknown as typeof fetch,
resolveFn: publicResolveFn,
});
const { downloadMSTeamsAttachments } = await load();
const fetchMock = createOkFetchMock("image/png");
const media = await downloadMSTeamsAttachments(
buildDownloadParams([{ contentType: "image/png", contentUrl: "https://x/img" }], {
fetchFn: fetchMock as unknown as typeof fetch,
}),
);
expect(fetchMock).toHaveBeenCalled();
expect(saveMediaBufferMock).toHaveBeenCalled();
@@ -149,50 +194,38 @@ describe("msteams attachments", () => {
});
it("supports Teams file.download.info downloadUrl attachments", async () => {
const fetchMock = vi.fn(async () => {
return new Response(Buffer.from("png"), {
status: 200,
headers: { "content-type": "image/png" },
});
});
const media = await downloadMSTeamsAttachments({
attachments: [
{
contentType: "application/vnd.microsoft.teams.file.download.info",
content: { downloadUrl: "https://x/dl", fileType: "png" },
},
],
maxBytes: 1024 * 1024,
allowHosts: ["x"],
fetchFn: fetchMock as unknown as typeof fetch,
resolveFn: publicResolveFn,
});
const { downloadMSTeamsAttachments } = await load();
const fetchMock = createOkFetchMock("image/png");
const media = await downloadMSTeamsAttachments(
buildDownloadParams(
[
{
contentType: "application/vnd.microsoft.teams.file.download.info",
content: { downloadUrl: "https://x/dl", fileType: "png" },
},
],
{ fetchFn: fetchMock as unknown as typeof fetch },
),
);
expect(fetchMock).toHaveBeenCalled();
expect(media).toHaveLength(1);
});
it("downloads non-image file attachments (PDF)", async () => {
const fetchMock = vi.fn(async () => {
return new Response(Buffer.from("pdf"), {
status: 200,
headers: { "content-type": "application/pdf" },
});
});
const { downloadMSTeamsAttachments } = await load();
const fetchMock = createOkFetchMock("application/pdf", "pdf");
detectMimeMock.mockResolvedValueOnce("application/pdf");
saveMediaBufferMock.mockResolvedValueOnce({
path: "/tmp/saved.pdf",
contentType: "application/pdf",
});
const media = await downloadMSTeamsAttachments({
attachments: [{ contentType: "application/pdf", contentUrl: "https://x/doc.pdf" }],
maxBytes: 1024 * 1024,
allowHosts: ["x"],
fetchFn: fetchMock as unknown as typeof fetch,
resolveFn: publicResolveFn,
});
const media = await downloadMSTeamsAttachments(
buildDownloadParams([{ contentType: "application/pdf", contentUrl: "https://x/doc.pdf" }], {
fetchFn: fetchMock as unknown as typeof fetch,
}),
);
expect(fetchMock).toHaveBeenCalled();
expect(media).toHaveLength(1);
@@ -201,48 +234,42 @@ describe("msteams attachments", () => {
});
it("downloads inline image URLs from html attachments", async () => {
const fetchMock = vi.fn(async () => {
return new Response(Buffer.from("png"), {
status: 200,
headers: { "content-type": "image/png" },
});
});
const media = await downloadMSTeamsAttachments({
attachments: [
{
contentType: "text/html",
content: '<img src="https://x/inline.png" />',
},
],
maxBytes: 1024 * 1024,
allowHosts: ["x"],
fetchFn: fetchMock as unknown as typeof fetch,
resolveFn: publicResolveFn,
});
const { downloadMSTeamsAttachments } = await load();
const fetchMock = createOkFetchMock("image/png");
const media = await downloadMSTeamsAttachments(
buildDownloadParams(
[
{
contentType: "text/html",
content: '<img src="https://x/inline.png" />',
},
],
{ fetchFn: fetchMock as unknown as typeof fetch },
),
);
expect(media).toHaveLength(1);
expect(fetchMock).toHaveBeenCalled();
});
it("stores inline data:image base64 payloads", async () => {
const { downloadMSTeamsAttachments } = await load();
const base64 = Buffer.from("png").toString("base64");
const media = await downloadMSTeamsAttachments({
attachments: [
const media = await downloadMSTeamsAttachments(
buildDownloadParams([
{
contentType: "text/html",
content: `<img src="data:image/png;base64,${base64}" />`,
},
],
maxBytes: 1024 * 1024,
allowHosts: ["x"],
});
]),
);
expect(media).toHaveLength(1);
expect(saveMediaBufferMock).toHaveBeenCalled();
});
it("retries with auth when the first request is unauthorized", async () => {
const { downloadMSTeamsAttachments } = await load();
const fetchMock = vi.fn(async (_url: string, opts?: RequestInit) => {
const headers = new Headers(opts?.headers);
const hasAuth = Boolean(headers.get("Authorization"));
@@ -255,21 +282,20 @@ describe("msteams attachments", () => {
});
});
const media = await downloadMSTeamsAttachments({
attachments: [{ contentType: "image/png", contentUrl: "https://x/img" }],
maxBytes: 1024 * 1024,
tokenProvider: { getAccessToken: vi.fn(async () => "token") },
allowHosts: ["x"],
authAllowHosts: ["x"],
fetchFn: fetchMock as unknown as typeof fetch,
resolveFn: publicResolveFn,
});
const media = await downloadMSTeamsAttachments(
buildDownloadParams([{ contentType: "image/png", contentUrl: "https://x/img" }], {
tokenProvider: { getAccessToken: vi.fn(async () => "token") },
authAllowHosts: ["x"],
fetchFn: fetchMock as unknown as typeof fetch,
}),
);
expect(fetchMock).toHaveBeenCalled();
expect(media).toHaveLength(1);
});
it("skips auth retries when the host is not in auth allowlist", async () => {
const { downloadMSTeamsAttachments } = await load();
const tokenProvider = { getAccessToken: vi.fn(async () => "token") };
const fetchMock = vi.fn(async (_url: string, opts?: RequestInit) => {
const headers = new Headers(opts?.headers);
@@ -283,17 +309,17 @@ describe("msteams attachments", () => {
});
});
const media = await downloadMSTeamsAttachments({
attachments: [
{ contentType: "image/png", contentUrl: "https://attacker.azureedge.net/img" },
],
maxBytes: 1024 * 1024,
tokenProvider,
allowHosts: ["azureedge.net"],
authAllowHosts: ["graph.microsoft.com"],
fetchFn: fetchMock as unknown as typeof fetch,
resolveFn: publicResolveFn,
});
const media = await downloadMSTeamsAttachments(
buildDownloadParams(
[{ contentType: "image/png", contentUrl: "https://attacker.azureedge.net/img" }],
{
tokenProvider,
allowHosts: ["azureedge.net"],
authAllowHosts: ["graph.microsoft.com"],
fetchFn: fetchMock as unknown as typeof fetch,
},
),
);
expect(media).toHaveLength(0);
expect(fetchMock).toHaveBeenCalled();
@@ -301,13 +327,15 @@ describe("msteams attachments", () => {
});
it("skips urls outside the allowlist", async () => {
const { downloadMSTeamsAttachments } = await load();
const fetchMock = vi.fn();
const media = await downloadMSTeamsAttachments({
attachments: [{ contentType: "image/png", contentUrl: "https://evil.test/img" }],
maxBytes: 1024 * 1024,
allowHosts: ["graph.microsoft.com"],
fetchFn: fetchMock as unknown as typeof fetch,
});
const media = await downloadMSTeamsAttachments(
buildDownloadParams([{ contentType: "image/png", contentUrl: "https://evil.test/img" }], {
allowHosts: ["graph.microsoft.com"],
resolveFn: undefined,
fetchFn: fetchMock as unknown as typeof fetch,
}),
);
expect(media).toHaveLength(0);
expect(fetchMock).not.toHaveBeenCalled();
@@ -316,6 +344,7 @@ describe("msteams attachments", () => {
describe("buildMSTeamsGraphMessageUrls", () => {
it("builds channel message urls", async () => {
const { buildMSTeamsGraphMessageUrls } = await load();
const urls = buildMSTeamsGraphMessageUrls({
conversationType: "channel",
conversationId: "19:thread@thread.tacv2",
@@ -326,6 +355,7 @@ describe("msteams attachments", () => {
});
it("builds channel reply urls when replyToId is present", async () => {
const { buildMSTeamsGraphMessageUrls } = await load();
const urls = buildMSTeamsGraphMessageUrls({
conversationType: "channel",
messageId: "reply-id",
@@ -338,6 +368,7 @@ describe("msteams attachments", () => {
});
it("builds chat message urls", async () => {
const { buildMSTeamsGraphMessageUrls } = await load();
const urls = buildMSTeamsGraphMessageUrls({
conversationType: "groupChat",
conversationId: "19:chat@thread.v2",
@@ -349,6 +380,7 @@ describe("msteams attachments", () => {
describe("downloadMSTeamsGraphMedia", () => {
it("downloads hostedContents images", async () => {
const { downloadMSTeamsGraphMedia } = await load();
const base64 = Buffer.from("png").toString("base64");
const fetchMock = vi.fn(async (url: string) => {
if (url.endsWith("/hostedContents")) {
@@ -371,12 +403,9 @@ describe("msteams attachments", () => {
return new Response("not found", { status: 404 });
});
const media = await downloadMSTeamsGraphMedia({
messageUrl: "https://graph.microsoft.com/v1.0/chats/19%3Achat/messages/123",
tokenProvider: { getAccessToken: vi.fn(async () => "token") },
maxBytes: 1024 * 1024,
fetchFn: fetchMock as unknown as typeof fetch,
});
const media = await downloadMSTeamsGraphMedia(
buildDownloadGraphParams(fetchMock as unknown as typeof fetch),
);
expect(media.media).toHaveLength(1);
expect(fetchMock).toHaveBeenCalled();
@@ -384,6 +413,7 @@ describe("msteams attachments", () => {
});
it("merges SharePoint reference attachments with hosted content", async () => {
const { downloadMSTeamsGraphMedia } = await load();
const hostedBase64 = Buffer.from("png").toString("base64");
const shareUrl = "https://contoso.sharepoint.com/site/file";
const fetchMock = vi.fn(async (url: string) => {
@@ -440,17 +470,15 @@ describe("msteams attachments", () => {
return new Response("not found", { status: 404 });
});
const media = await downloadMSTeamsGraphMedia({
messageUrl: "https://graph.microsoft.com/v1.0/chats/19%3Achat/messages/123",
tokenProvider: { getAccessToken: vi.fn(async () => "token") },
maxBytes: 1024 * 1024,
fetchFn: fetchMock as unknown as typeof fetch,
});
const media = await downloadMSTeamsGraphMedia(
buildDownloadGraphParams(fetchMock as unknown as typeof fetch),
);
expect(media.media).toHaveLength(2);
});
it("blocks SharePoint redirects to hosts outside allowHosts", async () => {
const { downloadMSTeamsGraphMedia } = await load();
const shareUrl = "https://contoso.sharepoint.com/site/file";
const escapedUrl = "https://evil.example/internal.pdf";
fetchRemoteMediaMock.mockImplementationOnce(async (params) => {
@@ -515,13 +543,11 @@ describe("msteams attachments", () => {
return new Response("not found", { status: 404 });
});
const media = await downloadMSTeamsGraphMedia({
messageUrl: "https://graph.microsoft.com/v1.0/chats/19%3Achat/messages/123",
tokenProvider: { getAccessToken: vi.fn(async () => "token") },
maxBytes: 1024 * 1024,
allowHosts: ["graph.microsoft.com", "contoso.sharepoint.com"],
fetchFn: fetchMock as unknown as typeof fetch,
});
const media = await downloadMSTeamsGraphMedia(
buildDownloadGraphParams(fetchMock as unknown as typeof fetch, {
allowHosts: ["graph.microsoft.com", "contoso.sharepoint.com"],
}),
);
expect(media.media).toHaveLength(0);
const calledUrls = fetchMock.mock.calls.map((call) => String(call[0]));
@@ -534,6 +560,7 @@ describe("msteams attachments", () => {
describe("buildMSTeamsMediaPayload", () => {
it("returns single and multi-file fields", async () => {
const { buildMSTeamsMediaPayload } = await load();
const payload = buildMSTeamsMediaPayload([
{ path: "/tmp/a.png", contentType: "image/png" },
{ path: "/tmp/b.png", contentType: "image/png" },

View File

@@ -49,6 +49,28 @@ const runtimeStub = {
},
} as unknown as PluginRuntime;
const createNoopAdapter = (): MSTeamsAdapter => ({
continueConversation: async () => {},
process: async () => {},
});
const createRecordedSendActivity = (
sink: string[],
failFirstWithStatusCode?: number,
): ((activity: unknown) => Promise<{ id: string }>) => {
let attempts = 0;
return async (activity: unknown) => {
const { text } = activity as { text?: string };
const content = text ?? "";
sink.push(content);
attempts += 1;
if (failFirstWithStatusCode !== undefined && attempts === 1) {
throw Object.assign(new Error("send failed"), { statusCode: failFirstWithStatusCode });
}
return { id: `id:${content}` };
};
};
describe("msteams messenger", () => {
beforeEach(() => {
setMSTeamsRuntime(runtimeStub);
@@ -117,17 +139,9 @@ describe("msteams messenger", () => {
it("sends thread messages via the provided context", async () => {
const sent: string[] = [];
const ctx = {
sendActivity: async (activity: unknown) => {
const { text } = activity as { text?: string };
sent.push(text ?? "");
return { id: `id:${text ?? ""}` };
},
};
const adapter: MSTeamsAdapter = {
continueConversation: async () => {},
process: async () => {},
sendActivity: createRecordedSendActivity(sent),
};
const adapter = createNoopAdapter();
const ids = await sendMSTeamsMessages({
replyStyle: "thread",
@@ -149,11 +163,7 @@ describe("msteams messenger", () => {
continueConversation: async (_appId, reference, logic) => {
seen.reference = reference;
await logic({
sendActivity: async (activity: unknown) => {
const { text } = activity as { text?: string };
seen.texts.push(text ?? "");
return { id: `id:${text ?? ""}` };
},
sendActivity: createRecordedSendActivity(seen.texts),
});
},
process: async () => {},
@@ -192,10 +202,7 @@ describe("msteams messenger", () => {
},
};
const adapter: MSTeamsAdapter = {
continueConversation: async () => {},
process: async () => {},
};
const adapter = createNoopAdapter();
const ids = await sendMSTeamsMessages({
replyStyle: "thread",
@@ -242,20 +249,9 @@ describe("msteams messenger", () => {
const retryEvents: Array<{ nextAttempt: number; delayMs: number }> = [];
const ctx = {
sendActivity: async (activity: unknown) => {
const { text } = activity as { text?: string };
attempts.push(text ?? "");
if (attempts.length === 1) {
throw Object.assign(new Error("throttled"), { statusCode: 429 });
}
return { id: `id:${text ?? ""}` };
},
};
const adapter: MSTeamsAdapter = {
continueConversation: async () => {},
process: async () => {},
sendActivity: createRecordedSendActivity(attempts, 429),
};
const adapter = createNoopAdapter();
const ids = await sendMSTeamsMessages({
replyStyle: "thread",
@@ -280,10 +276,7 @@ describe("msteams messenger", () => {
},
};
const adapter: MSTeamsAdapter = {
continueConversation: async () => {},
process: async () => {},
};
const adapter = createNoopAdapter();
await expect(
sendMSTeamsMessages({
@@ -303,18 +296,7 @@ describe("msteams messenger", () => {
const adapter: MSTeamsAdapter = {
continueConversation: async (_appId, _reference, logic) => {
await logic({
sendActivity: async (activity: unknown) => {
const { text } = activity as { text?: string };
attempts.push(text ?? "");
if (attempts.length === 1) {
throw Object.assign(new Error("server error"), {
statusCode: 503,
});
}
return { id: `id:${text ?? ""}` };
},
});
await logic({ sendActivity: createRecordedSendActivity(attempts, 503) });
},
process: async () => {},
};

View File

@@ -204,6 +204,23 @@ describe("nostr-profile-http", () => {
});
describe("PUT /api/channels/nostr/:accountId/profile", () => {
async function expectPrivatePictureRejected(pictureUrl: string) {
const ctx = createMockContext();
const handler = createNostrProfileHttpHandler(ctx);
const req = createMockRequest("PUT", "/api/channels/nostr/default/profile", {
name: "hacker",
picture: pictureUrl,
});
const res = createMockResponse();
await handler(req, res);
expect(res._getStatusCode()).toBe(400);
const data = JSON.parse(res._getData());
expect(data.ok).toBe(false);
expect(data.error).toContain("private");
}
it("validates profile and publishes", async () => {
const ctx = createMockContext();
const handler = createNostrProfileHttpHandler(ctx);
@@ -263,37 +280,11 @@ describe("nostr-profile-http", () => {
});
it("rejects private IP in picture URL (SSRF protection)", async () => {
const ctx = createMockContext();
const handler = createNostrProfileHttpHandler(ctx);
const req = createMockRequest("PUT", "/api/channels/nostr/default/profile", {
name: "hacker",
picture: "https://127.0.0.1/evil.jpg",
});
const res = createMockResponse();
await handler(req, res);
expect(res._getStatusCode()).toBe(400);
const data = JSON.parse(res._getData());
expect(data.ok).toBe(false);
expect(data.error).toContain("private");
await expectPrivatePictureRejected("https://127.0.0.1/evil.jpg");
});
it("rejects ISATAP-embedded private IPv4 in picture URL", async () => {
const ctx = createMockContext();
const handler = createNostrProfileHttpHandler(ctx);
const req = createMockRequest("PUT", "/api/channels/nostr/default/profile", {
name: "hacker",
picture: "https://[2001:db8:1234::5efe:127.0.0.1]/evil.jpg",
});
const res = createMockResponse();
await handler(req, res);
expect(res._getStatusCode()).toBe(400);
const data = JSON.parse(res._getData());
expect(data.ok).toBe(false);
expect(data.error).toContain("private");
await expectPrivatePictureRejected("https://[2001:db8:1234::5efe:127.0.0.1]/evil.jpg");
});
it("rejects non-https URLs", async () => {

View File

@@ -23,14 +23,14 @@ async function settleTimers<T>(promise: Promise<T>): Promise<T> {
return promise;
}
function mockSuccessResponse() {
function mockResponse(statusCode: number, body: string) {
const httpsRequest = vi.mocked(https.request);
httpsRequest.mockImplementation((_url: any, _opts: any, callback: any) => {
const res = new EventEmitter() as any;
res.statusCode = 200;
res.statusCode = statusCode;
process.nextTick(() => {
callback(res);
res.emit("data", Buffer.from('{"success":true}'));
res.emit("data", Buffer.from(body));
res.emit("end");
});
const req = new EventEmitter() as any;
@@ -41,22 +41,12 @@ function mockSuccessResponse() {
});
}
function mockSuccessResponse() {
mockResponse(200, '{"success":true}');
}
function mockFailureResponse(statusCode = 500) {
const httpsRequest = vi.mocked(https.request);
httpsRequest.mockImplementation((_url: any, _opts: any, callback: any) => {
const res = new EventEmitter() as any;
res.statusCode = statusCode;
process.nextTick(() => {
callback(res);
res.emit("data", Buffer.from("error"));
res.emit("end");
});
const req = new EventEmitter() as any;
req.write = vi.fn();
req.end = vi.fn();
req.destroy = vi.fn();
return req;
});
mockResponse(statusCode, "error");
}
describe("sendMessage", () => {

View File

@@ -80,6 +80,24 @@ describe("createWebhookHandler", () => {
};
});
async function expectForbiddenByPolicy(params: {
account: Partial<ResolvedSynologyChatAccount>;
bodyContains: string;
}) {
const handler = createWebhookHandler({
account: makeAccount(params.account),
deliver: vi.fn(),
log,
});
const req = makeReq("POST", validBody);
const res = makeRes();
await handler(req, res);
expect(res._status).toBe(403);
expect(res._body).toContain(params.bodyContains);
}
it("rejects non-POST methods with 405", async () => {
const handler = createWebhookHandler({
account: makeAccount(),
@@ -129,36 +147,20 @@ describe("createWebhookHandler", () => {
});
it("returns 403 for unauthorized user with allowlist policy", async () => {
const handler = createWebhookHandler({
account: makeAccount({
await expectForbiddenByPolicy({
account: {
dmPolicy: "allowlist",
allowedUserIds: ["456"],
}),
deliver: vi.fn(),
log,
},
bodyContains: "not authorized",
});
const req = makeReq("POST", validBody);
const res = makeRes();
await handler(req, res);
expect(res._status).toBe(403);
expect(res._body).toContain("not authorized");
});
it("returns 403 when DMs are disabled", async () => {
const handler = createWebhookHandler({
account: makeAccount({ dmPolicy: "disabled" }),
deliver: vi.fn(),
log,
await expectForbiddenByPolicy({
account: { dmPolicy: "disabled" },
bodyContains: "disabled",
});
const req = makeReq("POST", validBody);
const res = makeRes();
await handler(req, res);
expect(res._status).toBe(403);
expect(res._body).toContain("disabled");
});
it("returns 429 when rate limited", async () => {

View File

@@ -4,9 +4,9 @@ import type {
OpenClawConfig,
PluginRuntime,
ResolvedTelegramAccount,
RuntimeEnv,
} from "openclaw/plugin-sdk";
import { describe, expect, it, vi } from "vitest";
import { createRuntimeEnv } from "../../test-utils/runtime-env.js";
import { telegramPlugin } from "./channel.js";
import { setTelegramRuntime } from "./runtime.js";
@@ -25,20 +25,10 @@ function createCfg(): OpenClawConfig {
} as OpenClawConfig;
}
function createRuntimeEnv(): RuntimeEnv {
return {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn((code: number): never => {
throw new Error(`exit ${code}`);
}),
};
}
function createStartAccountCtx(params: {
cfg: OpenClawConfig;
accountId: string;
runtime: RuntimeEnv;
runtime: ReturnType<typeof createRuntimeEnv>;
}): ChannelGatewayContext<ResolvedTelegramAccount> {
const account = telegramPlugin.config.resolveAccount(
params.cfg,

View File

@@ -0,0 +1,12 @@
import type { RuntimeEnv } from "openclaw/plugin-sdk";
import { vi } from "vitest";
export function createRuntimeEnv(): RuntimeEnv {
return {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn((code: number): never => {
throw new Error(`exit ${code}`);
}),
};
}

View File

@@ -17,6 +17,38 @@ describe("checkTwitchAccessControl", () => {
channel: "testchannel",
};
function runAccessCheck(params: {
account?: Partial<TwitchAccountConfig>;
message?: Partial<TwitchChatMessage>;
}) {
return checkTwitchAccessControl({
message: {
...mockMessage,
...params.message,
},
account: {
...mockAccount,
...params.account,
},
botUsername: "testbot",
});
}
function expectSingleRoleAllowed(params: {
role: NonNullable<TwitchAccountConfig["allowedRoles"]>[number];
message: Partial<TwitchChatMessage>;
}) {
const result = runAccessCheck({
account: { allowedRoles: [params.role] },
message: {
message: "@testbot hello",
...params.message,
},
});
expect(result.allowed).toBe(true);
return result;
}
describe("when no restrictions are configured", () => {
it("allows messages that mention the bot (default requireMention)", () => {
const message: TwitchChatMessage = {
@@ -243,22 +275,10 @@ describe("checkTwitchAccessControl", () => {
describe("allowedRoles", () => {
it("allows users with matching role", () => {
const account: TwitchAccountConfig = {
...mockAccount,
allowedRoles: ["moderator"],
};
const message: TwitchChatMessage = {
...mockMessage,
message: "@testbot hello",
isMod: true,
};
const result = checkTwitchAccessControl({
message,
account,
botUsername: "testbot",
const result = expectSingleRoleAllowed({
role: "moderator",
message: { isMod: true },
});
expect(result.allowed).toBe(true);
expect(result.matchSource).toBe("role");
});
@@ -323,79 +343,31 @@ describe("checkTwitchAccessControl", () => {
});
it("handles moderator role", () => {
const account: TwitchAccountConfig = {
...mockAccount,
allowedRoles: ["moderator"],
};
const message: TwitchChatMessage = {
...mockMessage,
message: "@testbot hello",
isMod: true,
};
const result = checkTwitchAccessControl({
message,
account,
botUsername: "testbot",
expectSingleRoleAllowed({
role: "moderator",
message: { isMod: true },
});
expect(result.allowed).toBe(true);
});
it("handles subscriber role", () => {
const account: TwitchAccountConfig = {
...mockAccount,
allowedRoles: ["subscriber"],
};
const message: TwitchChatMessage = {
...mockMessage,
message: "@testbot hello",
isSub: true,
};
const result = checkTwitchAccessControl({
message,
account,
botUsername: "testbot",
expectSingleRoleAllowed({
role: "subscriber",
message: { isSub: true },
});
expect(result.allowed).toBe(true);
});
it("handles owner role", () => {
const account: TwitchAccountConfig = {
...mockAccount,
allowedRoles: ["owner"],
};
const message: TwitchChatMessage = {
...mockMessage,
message: "@testbot hello",
isOwner: true,
};
const result = checkTwitchAccessControl({
message,
account,
botUsername: "testbot",
expectSingleRoleAllowed({
role: "owner",
message: { isOwner: true },
});
expect(result.allowed).toBe(true);
});
it("handles vip role", () => {
const account: TwitchAccountConfig = {
...mockAccount,
allowedRoles: ["vip"],
};
const message: TwitchChatMessage = {
...mockMessage,
message: "@testbot hello",
isVip: true,
};
const result = checkTwitchAccessControl({
message,
account,
botUsername: "testbot",
expectSingleRoleAllowed({
role: "vip",
message: { isVip: true },
});
expect(result.allowed).toBe(true);
});
});
@@ -421,21 +393,15 @@ describe("checkTwitchAccessControl", () => {
});
it("checks allowlist before allowedRoles", () => {
const account: TwitchAccountConfig = {
...mockAccount,
allowFrom: ["123456"],
allowedRoles: ["owner"],
};
const message: TwitchChatMessage = {
...mockMessage,
message: "@testbot hello",
isOwner: false,
};
const result = checkTwitchAccessControl({
message,
account,
botUsername: "testbot",
const result = runAccessCheck({
account: {
allowFrom: ["123456"],
allowedRoles: ["owner"],
},
message: {
message: "@testbot hello",
isOwner: false,
},
});
expect(result.allowed).toBe(true);
expect(result.matchSource).toBe("allowlist");

View File

@@ -71,19 +71,26 @@ function createInboundInitiatedEvent(params: {
};
}
function createRejectingInboundContext(): {
ctx: CallManagerContext;
hangupCalls: HangupCallInput[];
} {
const hangupCalls: HangupCallInput[] = [];
const provider = createProvider({
hangupCall: async (input: HangupCallInput): Promise<void> => {
hangupCalls.push(input);
},
});
const ctx = createContext({
config: createInboundDisabledConfig(),
provider,
});
return { ctx, hangupCalls };
}
describe("processEvent (functional)", () => {
it("calls provider hangup when rejecting inbound call", () => {
const hangupCalls: HangupCallInput[] = [];
const provider = createProvider({
hangupCall: async (input: HangupCallInput): Promise<void> => {
hangupCalls.push(input);
},
});
const ctx = createContext({
config: createInboundDisabledConfig(),
provider,
});
const { ctx, hangupCalls } = createRejectingInboundContext();
const event = createInboundInitiatedEvent({
id: "evt-1",
providerCallId: "prov-1",
@@ -118,16 +125,7 @@ describe("processEvent (functional)", () => {
});
it("calls hangup only once for duplicate events for same rejected call", () => {
const hangupCalls: HangupCallInput[] = [];
const provider = createProvider({
hangupCall: async (input: HangupCallInput): Promise<void> => {
hangupCalls.push(input);
},
});
const ctx = createContext({
config: createInboundDisabledConfig(),
provider,
});
const { ctx, hangupCalls } = createRejectingInboundContext();
const event1 = createInboundInitiatedEvent({
id: "evt-init",
providerCallId: "prov-dup",