mirror of
https://fastgit.cc/github.com/openclaw/openclaw
synced 2026-04-30 14:02:56 +08:00
test: harden extension integration fixtures
This commit is contained in:
@@ -1,5 +1,4 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { bluebubblesMessageActions } from "./actions.js";
|
||||
import { sendBlueBubblesAttachment } from "./attachments.js";
|
||||
import { editBlueBubblesMessage, setGroupIconBlueBubbles } from "./chat.js";
|
||||
import { resolveBlueBubblesMessageId } from "./monitor.js";
|
||||
@@ -45,6 +44,9 @@ vi.mock("./probe.js", () => ({
|
||||
getCachedBlueBubblesPrivateApiStatus: vi.fn().mockReturnValue(null),
|
||||
}));
|
||||
|
||||
const freshActionsModulePath = "./actions.js?actions-test";
|
||||
const { bluebubblesMessageActions } = await import(freshActionsModulePath);
|
||||
|
||||
describe("bluebubblesMessageActions", () => {
|
||||
const describeMessageTool = bluebubblesMessageActions.describeMessageTool!;
|
||||
const supportsAction = bluebubblesMessageActions.supportsAction!;
|
||||
|
||||
@@ -1218,7 +1218,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
|
||||
'Assistant sent "replying now" [message_id:2]',
|
||||
expect.objectContaining({
|
||||
sessionKey: "agent:main:bluebubbles:dm:+15551234567",
|
||||
sessionKey: "agent:main:main",
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -1258,7 +1258,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
|
||||
'Assistant sent "replying now" [message_id:2]',
|
||||
expect.objectContaining({
|
||||
sessionKey: "agent:main:bluebubbles:dm:+15551234567",
|
||||
sessionKey: "agent:main:main",
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -1297,7 +1297,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
|
||||
'Assistant sent "replying now" [message_id:2]',
|
||||
expect.objectContaining({
|
||||
sessionKey: "agent:main:bluebubbles:dm:+15551234567",
|
||||
sessionKey: "agent:main:main",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,31 +1,24 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ClawdbotConfig } from "../runtime-api.js";
|
||||
|
||||
const resolveFeishuAccountMock = vi.hoisted(() => vi.fn());
|
||||
const createFeishuClientMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./accounts.js", () => ({
|
||||
resolveFeishuAccount: resolveFeishuAccountMock,
|
||||
}));
|
||||
|
||||
vi.mock("./client.js", () => ({
|
||||
createFeishuClient: createFeishuClientMock,
|
||||
}));
|
||||
|
||||
import {
|
||||
const freshDirectoryModulePath = "./directory.js?directory-test";
|
||||
const {
|
||||
listFeishuDirectoryGroups,
|
||||
listFeishuDirectoryGroupsLive,
|
||||
listFeishuDirectoryPeers,
|
||||
listFeishuDirectoryPeersLive,
|
||||
} from "./directory.js";
|
||||
} = await import(freshDirectoryModulePath);
|
||||
|
||||
describe("feishu directory (config-backed)", () => {
|
||||
const cfg = {} as ClawdbotConfig;
|
||||
|
||||
function makeStaticAccount() {
|
||||
return {
|
||||
configured: false,
|
||||
config: {
|
||||
function makeStaticCfg(): ClawdbotConfig {
|
||||
return {
|
||||
channels: {
|
||||
feishu: {
|
||||
allowFrom: ["user:alice", "user:bob"],
|
||||
dms: {
|
||||
"user:carla": {},
|
||||
@@ -35,13 +28,29 @@ describe("feishu directory (config-backed)", () => {
|
||||
},
|
||||
groupAllowFrom: ["chat-2"],
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
}
|
||||
|
||||
resolveFeishuAccountMock.mockImplementation(() => makeStaticAccount());
|
||||
function makeConfiguredCfg(): ClawdbotConfig {
|
||||
return {
|
||||
channels: {
|
||||
feishu: {
|
||||
...makeStaticCfg().channels?.feishu,
|
||||
appId: "cli_test_app_id",
|
||||
appSecret: "cli_test_app_secret",
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
}
|
||||
|
||||
describe("feishu directory (config-backed)", () => {
|
||||
beforeEach(() => {
|
||||
createFeishuClientMock.mockReset();
|
||||
});
|
||||
|
||||
it("merges allowFrom + dms into peer entries", async () => {
|
||||
const peers = await listFeishuDirectoryPeers({ cfg, query: "a" });
|
||||
const peers = await listFeishuDirectoryPeers({ cfg: makeStaticCfg(), query: "a" });
|
||||
expect(peers).toEqual([
|
||||
{ kind: "user", id: "alice" },
|
||||
{ kind: "user", id: "carla" },
|
||||
@@ -49,17 +58,18 @@ describe("feishu directory (config-backed)", () => {
|
||||
});
|
||||
|
||||
it("normalizes spaced provider-prefixed peer entries", async () => {
|
||||
resolveFeishuAccountMock.mockReturnValueOnce({
|
||||
configured: false,
|
||||
config: {
|
||||
allowFrom: [" feishu:user:ou_alice "],
|
||||
dms: {
|
||||
" lark:dm:ou_carla ": {},
|
||||
const cfg = {
|
||||
channels: {
|
||||
feishu: {
|
||||
allowFrom: [" feishu:user:ou_alice "],
|
||||
dms: {
|
||||
" lark:dm:ou_carla ": {},
|
||||
},
|
||||
groups: {},
|
||||
groupAllowFrom: [],
|
||||
},
|
||||
groups: {},
|
||||
groupAllowFrom: [],
|
||||
},
|
||||
});
|
||||
} as ClawdbotConfig;
|
||||
|
||||
const peers = await listFeishuDirectoryPeers({ cfg });
|
||||
expect(peers).toEqual([
|
||||
@@ -69,7 +79,7 @@ describe("feishu directory (config-backed)", () => {
|
||||
});
|
||||
|
||||
it("merges groups map + groupAllowFrom into group entries", async () => {
|
||||
const groups = await listFeishuDirectoryGroups({ cfg });
|
||||
const groups = await listFeishuDirectoryGroups({ cfg: makeStaticCfg() });
|
||||
expect(groups).toEqual([
|
||||
{ kind: "group", id: "chat-1" },
|
||||
{ kind: "group", id: "chat-2" },
|
||||
@@ -77,10 +87,6 @@ describe("feishu directory (config-backed)", () => {
|
||||
});
|
||||
|
||||
it("falls back to static peers on live lookup failure by default", async () => {
|
||||
resolveFeishuAccountMock.mockReturnValueOnce({
|
||||
...makeStaticAccount(),
|
||||
configured: true,
|
||||
});
|
||||
createFeishuClientMock.mockReturnValueOnce({
|
||||
contact: {
|
||||
user: {
|
||||
@@ -91,7 +97,7 @@ describe("feishu directory (config-backed)", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const peers = await listFeishuDirectoryPeersLive({ cfg, query: "a" });
|
||||
const peers = await listFeishuDirectoryPeersLive({ cfg: makeConfiguredCfg(), query: "a" });
|
||||
expect(peers).toEqual([
|
||||
{ kind: "user", id: "alice" },
|
||||
{ kind: "user", id: "carla" },
|
||||
@@ -99,10 +105,6 @@ describe("feishu directory (config-backed)", () => {
|
||||
});
|
||||
|
||||
it("surfaces live peer lookup failures when fallback is disabled", async () => {
|
||||
resolveFeishuAccountMock.mockReturnValueOnce({
|
||||
...makeStaticAccount(),
|
||||
configured: true,
|
||||
});
|
||||
createFeishuClientMock.mockReturnValueOnce({
|
||||
contact: {
|
||||
user: {
|
||||
@@ -113,16 +115,12 @@ describe("feishu directory (config-backed)", () => {
|
||||
},
|
||||
});
|
||||
|
||||
await expect(listFeishuDirectoryPeersLive({ cfg, fallbackToStatic: false })).rejects.toThrow(
|
||||
"token expired",
|
||||
);
|
||||
await expect(
|
||||
listFeishuDirectoryPeersLive({ cfg: makeConfiguredCfg(), fallbackToStatic: false }),
|
||||
).rejects.toThrow("token expired");
|
||||
});
|
||||
|
||||
it("surfaces live group lookup failures when fallback is disabled", async () => {
|
||||
resolveFeishuAccountMock.mockReturnValueOnce({
|
||||
...makeStaticAccount(),
|
||||
configured: true,
|
||||
});
|
||||
createFeishuClientMock.mockReturnValueOnce({
|
||||
im: {
|
||||
chat: {
|
||||
@@ -131,8 +129,8 @@ describe("feishu directory (config-backed)", () => {
|
||||
},
|
||||
});
|
||||
|
||||
await expect(listFeishuDirectoryGroupsLive({ cfg, fallbackToStatic: false })).rejects.toThrow(
|
||||
"forbidden",
|
||||
);
|
||||
await expect(
|
||||
listFeishuDirectoryGroupsLive({ cfg: makeConfiguredCfg(), fallbackToStatic: false }),
|
||||
).rejects.toThrow("forbidden");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,29 +1,34 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
dispatchReplyWithBufferedBlockDispatcher,
|
||||
finalizeInboundContextMock,
|
||||
registerPluginHttpRouteMock,
|
||||
resolveAgentRouteMock,
|
||||
} from "./channel.test-mocks.js";
|
||||
import { makeFormBody, makeReq, makeRes } from "./test-http-utils.js";
|
||||
import * as gatewayRuntimeModule from "./gateway-runtime.js";
|
||||
|
||||
type RegisteredRoute = {
|
||||
path: string;
|
||||
accountId: string;
|
||||
handler: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
|
||||
};
|
||||
const registerSynologyWebhookRouteMock = vi
|
||||
.spyOn(gatewayRuntimeModule, "registerSynologyWebhookRoute")
|
||||
.mockImplementation(() => vi.fn());
|
||||
|
||||
const freshChannelModulePath = "./channel.js?channel-integration-test";
|
||||
const { createSynologyChatPlugin } = await import(freshChannelModulePath);
|
||||
|
||||
async function expectPendingStartAccountPromise(
|
||||
result: Promise<unknown>,
|
||||
abortController: AbortController,
|
||||
) {
|
||||
expect(result).toBeInstanceOf(Promise);
|
||||
const resolved = await Promise.race([
|
||||
result,
|
||||
new Promise((r) => setTimeout(() => r("pending"), 50)),
|
||||
]);
|
||||
expect(resolved).toBe("pending");
|
||||
abortController.abort();
|
||||
await result;
|
||||
}
|
||||
|
||||
const { createSynologyChatPlugin } = await import("./channel.js");
|
||||
describe("Synology channel wiring integration", () => {
|
||||
beforeEach(() => {
|
||||
registerPluginHttpRouteMock.mockClear();
|
||||
dispatchReplyWithBufferedBlockDispatcher.mockClear();
|
||||
finalizeInboundContextMock.mockClear();
|
||||
resolveAgentRouteMock.mockClear();
|
||||
registerSynologyWebhookRouteMock.mockClear();
|
||||
registerSynologyWebhookRouteMock.mockImplementation(() => vi.fn());
|
||||
});
|
||||
|
||||
it("registers real webhook handler with resolved account config and enforces allowlist", async () => {
|
||||
it("registers the gateway route with resolved named-account config", async () => {
|
||||
const plugin = createSynologyChatPlugin();
|
||||
const abortController = new AbortController();
|
||||
const ctx = {
|
||||
@@ -50,35 +55,28 @@ describe("Synology channel wiring integration", () => {
|
||||
};
|
||||
|
||||
const started = plugin.gateway.startAccount(ctx);
|
||||
expect(registerPluginHttpRouteMock).toHaveBeenCalledTimes(1);
|
||||
expect(registerSynologyWebhookRouteMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
const firstCall = registerPluginHttpRouteMock.mock.calls[0];
|
||||
const firstCall = registerSynologyWebhookRouteMock.mock.calls[0];
|
||||
expect(firstCall).toBeTruthy();
|
||||
if (!firstCall) throw new Error("Expected registerPluginHttpRoute to be called");
|
||||
if (!firstCall) throw new Error("Expected registerSynologyWebhookRoute to be called");
|
||||
|
||||
const registered = firstCall[0];
|
||||
expect(registered.path).toBe("/webhook/synology-alerts");
|
||||
expect(registered.accountId).toBe("alerts");
|
||||
expect(registered.account).toMatchObject({
|
||||
accountId: "alerts",
|
||||
token: "valid-token",
|
||||
incomingUrl: "https://nas.example.com/incoming",
|
||||
webhookPath: "/webhook/synology-alerts",
|
||||
webhookPathSource: "explicit",
|
||||
dmPolicy: "allowlist",
|
||||
allowedUserIds: ["456"],
|
||||
});
|
||||
|
||||
const req = makeReq(
|
||||
"POST",
|
||||
makeFormBody({
|
||||
token: "valid-token",
|
||||
user_id: "123",
|
||||
username: "unauthorized-user",
|
||||
text: "Hello",
|
||||
}),
|
||||
);
|
||||
const res = makeRes();
|
||||
await registered.handler(req, res);
|
||||
|
||||
expect(res._status).toBe(403);
|
||||
expect(res._body).toContain("not authorized");
|
||||
expect(dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
||||
abortController.abort();
|
||||
await started;
|
||||
await expectPendingStartAccountPromise(started, abortController);
|
||||
});
|
||||
|
||||
it("isolates same user_id across different accounts", async () => {
|
||||
it("passes distinct resolved accounts for separate named-account starts", async () => {
|
||||
const plugin = createSynologyChatPlugin();
|
||||
const alphaAbortController = new AbortController();
|
||||
const betaAbortController = new AbortController();
|
||||
@@ -92,14 +90,14 @@ describe("Synology channel wiring integration", () => {
|
||||
token: "token-alpha",
|
||||
incomingUrl: "https://nas.example.com/incoming-alpha",
|
||||
webhookPath: "/webhook/synology-alpha",
|
||||
dmPolicy: "open",
|
||||
dmPolicy: "open" as const,
|
||||
},
|
||||
beta: {
|
||||
enabled: true,
|
||||
token: "token-beta",
|
||||
incomingUrl: "https://nas.example.com/incoming-beta",
|
||||
webhookPath: "/webhook/synology-beta",
|
||||
dmPolicy: "open",
|
||||
dmPolicy: "open" as const,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -122,51 +120,33 @@ describe("Synology channel wiring integration", () => {
|
||||
abortSignal: betaAbortController.signal,
|
||||
});
|
||||
|
||||
expect(registerPluginHttpRouteMock).toHaveBeenCalledTimes(2);
|
||||
const alphaRoute = registerPluginHttpRouteMock.mock.calls[0]?.[0];
|
||||
const betaRoute = registerPluginHttpRouteMock.mock.calls[1]?.[0];
|
||||
if (!alphaRoute || !betaRoute) {
|
||||
expect(registerSynologyWebhookRouteMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
const alphaCall = registerSynologyWebhookRouteMock.mock.calls[0]?.[0];
|
||||
const betaCall = registerSynologyWebhookRouteMock.mock.calls[1]?.[0];
|
||||
if (!alphaCall || !betaCall) {
|
||||
throw new Error("Expected both Synology Chat routes to register");
|
||||
}
|
||||
|
||||
const alphaReq = makeReq(
|
||||
"POST",
|
||||
makeFormBody({
|
||||
expect(alphaCall).toMatchObject({
|
||||
accountId: "alpha",
|
||||
account: {
|
||||
accountId: "alpha",
|
||||
token: "token-alpha",
|
||||
user_id: "123",
|
||||
username: "alice",
|
||||
text: "alpha secret",
|
||||
}),
|
||||
);
|
||||
const alphaRes = makeRes();
|
||||
await alphaRoute.handler(alphaReq, alphaRes);
|
||||
|
||||
const betaReq = makeReq(
|
||||
"POST",
|
||||
makeFormBody({
|
||||
token: "token-beta",
|
||||
user_id: "123",
|
||||
username: "bob",
|
||||
text: "beta secret",
|
||||
}),
|
||||
);
|
||||
const betaRes = makeRes();
|
||||
await betaRoute.handler(betaReq, betaRes);
|
||||
|
||||
expect(alphaRes._status).toBe(204);
|
||||
expect(betaRes._status).toBe(204);
|
||||
expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(2);
|
||||
expect(finalizeInboundContextMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
const alphaCtx = finalizeInboundContextMock.mock.calls[0]?.[0];
|
||||
const betaCtx = finalizeInboundContextMock.mock.calls[1]?.[0];
|
||||
expect(alphaCtx).toMatchObject({
|
||||
AccountId: "alpha",
|
||||
SessionKey: "agent:agent-alpha:synology-chat:alpha:direct:123",
|
||||
incomingUrl: "https://nas.example.com/incoming-alpha",
|
||||
webhookPath: "/webhook/synology-alpha",
|
||||
webhookPathSource: "explicit",
|
||||
},
|
||||
});
|
||||
expect(betaCtx).toMatchObject({
|
||||
AccountId: "beta",
|
||||
SessionKey: "agent:agent-beta:synology-chat:beta:direct:123",
|
||||
expect(betaCall).toMatchObject({
|
||||
accountId: "beta",
|
||||
account: {
|
||||
accountId: "beta",
|
||||
token: "token-beta",
|
||||
incomingUrl: "https://nas.example.com/incoming-beta",
|
||||
webhookPath: "/webhook/synology-beta",
|
||||
webhookPathSource: "explicit",
|
||||
},
|
||||
});
|
||||
|
||||
alphaAbortController.abort();
|
||||
|
||||
Reference in New Issue
Block a user