test: harden gateway mocks and env isolation
This commit is contained in:
@@ -1,9 +1,17 @@
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
|
import fsSync from "node:fs";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { vi } from "vitest";
|
import { vi } from "vitest";
|
||||||
|
|
||||||
|
import type { ChannelPlugin, ChannelOutboundAdapter } from "../channels/plugins/types.js";
|
||||||
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
|
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
|
||||||
|
import type { AgentBinding } from "../config/types.agents.js";
|
||||||
|
import type { HooksConfig } from "../config/types.hooks.js";
|
||||||
|
import type { PluginRegistry } from "../plugins/registry.js";
|
||||||
|
import { setActivePluginRegistry } from "../plugins/runtime.js";
|
||||||
|
import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js";
|
||||||
|
|
||||||
export type BridgeClientInfo = {
|
export type BridgeClientInfo = {
|
||||||
nodeId: string;
|
nodeId: string;
|
||||||
@@ -34,6 +42,136 @@ export type BridgeStartOpts = {
|
|||||||
>;
|
>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type StubChannelOptions = {
|
||||||
|
id: ChannelPlugin["id"];
|
||||||
|
label: string;
|
||||||
|
summary?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createStubOutboundAdapter = (channelId: ChannelPlugin["id"]): ChannelOutboundAdapter => ({
|
||||||
|
deliveryMode: "direct",
|
||||||
|
sendText: async () => ({
|
||||||
|
channel: channelId,
|
||||||
|
messageId: `${channelId}-msg`,
|
||||||
|
}),
|
||||||
|
sendMedia: async () => ({
|
||||||
|
channel: channelId,
|
||||||
|
messageId: `${channelId}-msg`,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createStubChannelPlugin = (params: StubChannelOptions): ChannelPlugin => ({
|
||||||
|
id: params.id,
|
||||||
|
meta: {
|
||||||
|
id: params.id,
|
||||||
|
label: params.label,
|
||||||
|
selectionLabel: params.label,
|
||||||
|
docsPath: `/channels/${params.id}`,
|
||||||
|
blurb: "test stub.",
|
||||||
|
},
|
||||||
|
capabilities: { chatTypes: ["direct"] },
|
||||||
|
config: {
|
||||||
|
listAccountIds: () => [DEFAULT_ACCOUNT_ID],
|
||||||
|
resolveAccount: () => ({}),
|
||||||
|
isConfigured: async () => false,
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
buildChannelSummary: async () => ({
|
||||||
|
configured: false,
|
||||||
|
...(params.summary ? params.summary : {}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
outbound: createStubOutboundAdapter(params.id),
|
||||||
|
messaging: {
|
||||||
|
normalizeTarget: (raw) => raw,
|
||||||
|
},
|
||||||
|
gateway: {
|
||||||
|
logoutAccount: async () => ({
|
||||||
|
cleared: false,
|
||||||
|
envToken: false,
|
||||||
|
loggedOut: false,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const createStubPluginRegistry = (): PluginRegistry => ({
|
||||||
|
plugins: [],
|
||||||
|
tools: [],
|
||||||
|
hooks: [],
|
||||||
|
typedHooks: [],
|
||||||
|
channels: [
|
||||||
|
{
|
||||||
|
pluginId: "whatsapp",
|
||||||
|
source: "test",
|
||||||
|
plugin: createStubChannelPlugin({ id: "whatsapp", label: "WhatsApp" }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pluginId: "telegram",
|
||||||
|
source: "test",
|
||||||
|
plugin: createStubChannelPlugin({
|
||||||
|
id: "telegram",
|
||||||
|
label: "Telegram",
|
||||||
|
summary: { tokenSource: "none", lastProbeAt: null },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pluginId: "discord",
|
||||||
|
source: "test",
|
||||||
|
plugin: createStubChannelPlugin({ id: "discord", label: "Discord" }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pluginId: "slack",
|
||||||
|
source: "test",
|
||||||
|
plugin: createStubChannelPlugin({ id: "slack", label: "Slack" }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pluginId: "signal",
|
||||||
|
source: "test",
|
||||||
|
plugin: createStubChannelPlugin({
|
||||||
|
id: "signal",
|
||||||
|
label: "Signal",
|
||||||
|
summary: { lastProbeAt: null },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pluginId: "imessage",
|
||||||
|
source: "test",
|
||||||
|
plugin: createStubChannelPlugin({ id: "imessage", label: "iMessage" }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pluginId: "msteams",
|
||||||
|
source: "test",
|
||||||
|
plugin: createStubChannelPlugin({ id: "msteams", label: "Microsoft Teams" }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pluginId: "matrix",
|
||||||
|
source: "test",
|
||||||
|
plugin: createStubChannelPlugin({ id: "matrix", label: "Matrix" }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pluginId: "zalo",
|
||||||
|
source: "test",
|
||||||
|
plugin: createStubChannelPlugin({ id: "zalo", label: "Zalo" }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pluginId: "zalouser",
|
||||||
|
source: "test",
|
||||||
|
plugin: createStubChannelPlugin({ id: "zalouser", label: "Zalo Personal" }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pluginId: "bluebubbles",
|
||||||
|
source: "test",
|
||||||
|
plugin: createStubChannelPlugin({ id: "bluebubbles", label: "BlueBubbles" }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
providers: [],
|
||||||
|
gatewayHandlers: {},
|
||||||
|
httpHandlers: [],
|
||||||
|
cliRegistrars: [],
|
||||||
|
services: [],
|
||||||
|
diagnostics: [],
|
||||||
|
});
|
||||||
|
|
||||||
const hoisted = vi.hoisted(() => ({
|
const hoisted = vi.hoisted(() => ({
|
||||||
bridgeStartCalls: [] as BridgeStartOpts[],
|
bridgeStartCalls: [] as BridgeStartOpts[],
|
||||||
bridgeInvoke: vi.fn(async () => ({
|
bridgeInvoke: vi.fn(async () => ({
|
||||||
@@ -70,6 +208,21 @@ const hoisted = vi.hoisted(() => ({
|
|||||||
sendWhatsAppMock: vi.fn().mockResolvedValue({ messageId: "msg-1", toJid: "jid-1" }),
|
sendWhatsAppMock: vi.fn().mockResolvedValue({ messageId: "msg-1", toJid: "jid-1" }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const pluginRegistryState = {
|
||||||
|
registry: createStubPluginRegistry(),
|
||||||
|
};
|
||||||
|
setActivePluginRegistry(pluginRegistryState.registry);
|
||||||
|
|
||||||
|
export const setTestPluginRegistry = (registry: PluginRegistry) => {
|
||||||
|
pluginRegistryState.registry = registry;
|
||||||
|
setActivePluginRegistry(registry);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resetTestPluginRegistry = () => {
|
||||||
|
pluginRegistryState.registry = createStubPluginRegistry();
|
||||||
|
setActivePluginRegistry(pluginRegistryState.registry);
|
||||||
|
};
|
||||||
|
|
||||||
const testConfigRoot = {
|
const testConfigRoot = {
|
||||||
value: path.join(os.tmpdir(), `clawdbot-gateway-test-${process.pid}-${crypto.randomUUID()}`),
|
value: path.join(os.tmpdir(), `clawdbot-gateway-test-${process.pid}-${crypto.randomUUID()}`),
|
||||||
};
|
};
|
||||||
@@ -91,7 +244,7 @@ export const agentCommand = hoisted.agentCommand;
|
|||||||
export const testState = {
|
export const testState = {
|
||||||
agentConfig: undefined as Record<string, unknown> | undefined,
|
agentConfig: undefined as Record<string, unknown> | undefined,
|
||||||
agentsConfig: undefined as Record<string, unknown> | undefined,
|
agentsConfig: undefined as Record<string, unknown> | undefined,
|
||||||
bindingsConfig: undefined as Array<Record<string, unknown>> | undefined,
|
bindingsConfig: undefined as AgentBinding[] | undefined,
|
||||||
channelsConfig: undefined as Record<string, unknown> | undefined,
|
channelsConfig: undefined as Record<string, unknown> | undefined,
|
||||||
sessionStorePath: undefined as string | undefined,
|
sessionStorePath: undefined as string | undefined,
|
||||||
sessionConfig: undefined as Record<string, unknown> | undefined,
|
sessionConfig: undefined as Record<string, unknown> | undefined,
|
||||||
@@ -100,7 +253,7 @@ export const testState = {
|
|||||||
cronEnabled: false as boolean | undefined,
|
cronEnabled: false as boolean | undefined,
|
||||||
gatewayBind: undefined as "auto" | "lan" | "tailnet" | "loopback" | undefined,
|
gatewayBind: undefined as "auto" | "lan" | "tailnet" | "loopback" | undefined,
|
||||||
gatewayAuth: undefined as Record<string, unknown> | undefined,
|
gatewayAuth: undefined as Record<string, unknown> | undefined,
|
||||||
hooksConfig: undefined as Record<string, unknown> | undefined,
|
hooksConfig: undefined as HooksConfig | undefined,
|
||||||
canvasHostPort: undefined as number | undefined,
|
canvasHostPort: undefined as number | undefined,
|
||||||
legacyIssues: [] as Array<{ path: string; message: string }>,
|
legacyIssues: [] as Array<{ path: string; message: string }>,
|
||||||
legacyParsed: {} as Record<string, unknown>,
|
legacyParsed: {} as Record<string, unknown>,
|
||||||
@@ -262,61 +415,109 @@ vi.mock("../config/config.js", async () => {
|
|||||||
changes: testState.migrationChanges,
|
changes: testState.migrationChanges,
|
||||||
}),
|
}),
|
||||||
loadConfig: () => {
|
loadConfig: () => {
|
||||||
const base = {
|
const configPath = resolveConfigPath();
|
||||||
agents: (() => {
|
let fileConfig: Record<string, unknown> = {};
|
||||||
const defaults = {
|
try {
|
||||||
model: "anthropic/claude-opus-4-5",
|
if (fsSync.existsSync(configPath)) {
|
||||||
workspace: path.join(os.tmpdir(), "clawd-gateway-test"),
|
const raw = fsSync.readFileSync(configPath, "utf-8");
|
||||||
...testState.agentConfig,
|
fileConfig = JSON.parse(raw) as Record<string, unknown>;
|
||||||
};
|
}
|
||||||
if (testState.agentsConfig) {
|
} catch {
|
||||||
return { ...testState.agentsConfig, defaults };
|
fileConfig = {};
|
||||||
}
|
}
|
||||||
return { defaults };
|
|
||||||
})(),
|
const fileAgents =
|
||||||
bindings: testState.bindingsConfig,
|
fileConfig.agents && typeof fileConfig.agents === "object" && !Array.isArray(fileConfig.agents)
|
||||||
channels: (() => {
|
? (fileConfig.agents as Record<string, unknown>)
|
||||||
const baseChannels =
|
: {};
|
||||||
testState.channelsConfig && typeof testState.channelsConfig === "object"
|
const fileDefaults =
|
||||||
? { ...testState.channelsConfig }
|
fileAgents.defaults && typeof fileAgents.defaults === "object" && !Array.isArray(fileAgents.defaults)
|
||||||
: {};
|
? (fileAgents.defaults as Record<string, unknown>)
|
||||||
const existing = baseChannels.whatsapp;
|
: {};
|
||||||
const mergedWhatsApp: Record<string, unknown> =
|
const defaults = {
|
||||||
existing && typeof existing === "object" && !Array.isArray(existing)
|
model: { primary: "anthropic/claude-opus-4-5" },
|
||||||
? { ...existing }
|
workspace: path.join(os.tmpdir(), "clawd-gateway-test"),
|
||||||
: {};
|
...fileDefaults,
|
||||||
if (testState.allowFrom !== undefined) {
|
...testState.agentConfig,
|
||||||
mergedWhatsApp.allowFrom = testState.allowFrom;
|
};
|
||||||
}
|
const agents = testState.agentsConfig
|
||||||
baseChannels.whatsapp = mergedWhatsApp;
|
? { ...fileAgents, ...testState.agentsConfig, defaults }
|
||||||
return baseChannels;
|
: { ...fileAgents, defaults };
|
||||||
})(),
|
|
||||||
session: {
|
const fileBindings = Array.isArray(fileConfig.bindings)
|
||||||
mainKey: "main",
|
? (fileConfig.bindings as AgentBinding[])
|
||||||
store: testState.sessionStorePath,
|
: undefined;
|
||||||
...testState.sessionConfig,
|
|
||||||
},
|
const fileChannels =
|
||||||
gateway: (() => {
|
fileConfig.channels && typeof fileConfig.channels === "object" && !Array.isArray(fileConfig.channels)
|
||||||
const gateway: Record<string, unknown> = {};
|
? ({ ...(fileConfig.channels as Record<string, unknown>) } as Record<string, unknown>)
|
||||||
if (testState.gatewayBind) gateway.bind = testState.gatewayBind;
|
: {};
|
||||||
if (testState.gatewayAuth) gateway.auth = testState.gatewayAuth;
|
const overrideChannels =
|
||||||
return Object.keys(gateway).length > 0 ? gateway : undefined;
|
testState.channelsConfig && typeof testState.channelsConfig === "object"
|
||||||
})(),
|
? { ...(testState.channelsConfig as Record<string, unknown>) }
|
||||||
canvasHost: (() => {
|
: {};
|
||||||
const canvasHost: Record<string, unknown> = {};
|
const mergedChannels = { ...fileChannels, ...overrideChannels };
|
||||||
if (typeof testState.canvasHostPort === "number")
|
if (testState.allowFrom !== undefined) {
|
||||||
canvasHost.port = testState.canvasHostPort;
|
const existing =
|
||||||
return Object.keys(canvasHost).length > 0 ? canvasHost : undefined;
|
mergedChannels.whatsapp && typeof mergedChannels.whatsapp === "object" && !Array.isArray(mergedChannels.whatsapp)
|
||||||
})(),
|
? (mergedChannels.whatsapp as Record<string, unknown>)
|
||||||
hooks: testState.hooksConfig,
|
: {};
|
||||||
cron: (() => {
|
mergedChannels.whatsapp = {
|
||||||
const cron: Record<string, unknown> = {};
|
...existing,
|
||||||
if (typeof testState.cronEnabled === "boolean") cron.enabled = testState.cronEnabled;
|
allowFrom: testState.allowFrom,
|
||||||
if (typeof testState.cronStorePath === "string") cron.store = testState.cronStorePath;
|
};
|
||||||
return Object.keys(cron).length > 0 ? cron : undefined;
|
}
|
||||||
})(),
|
const channels = Object.keys(mergedChannels).length > 0 ? mergedChannels : undefined;
|
||||||
} as ReturnType<typeof actual.loadConfig>;
|
|
||||||
return applyPluginAutoEnable({ config: base }).config;
|
const fileSession =
|
||||||
|
fileConfig.session && typeof fileConfig.session === "object" && !Array.isArray(fileConfig.session)
|
||||||
|
? (fileConfig.session as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
const session: Record<string, unknown> = {
|
||||||
|
...fileSession,
|
||||||
|
mainKey: fileSession.mainKey ?? "main",
|
||||||
|
};
|
||||||
|
if (typeof testState.sessionStorePath === "string") session.store = testState.sessionStorePath;
|
||||||
|
if (testState.sessionConfig) Object.assign(session, testState.sessionConfig);
|
||||||
|
|
||||||
|
const fileGateway =
|
||||||
|
fileConfig.gateway && typeof fileConfig.gateway === "object" && !Array.isArray(fileConfig.gateway)
|
||||||
|
? ({ ...(fileConfig.gateway as Record<string, unknown>) } as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
if (testState.gatewayBind) fileGateway.bind = testState.gatewayBind;
|
||||||
|
if (testState.gatewayAuth) fileGateway.auth = testState.gatewayAuth;
|
||||||
|
const gateway = Object.keys(fileGateway).length > 0 ? fileGateway : undefined;
|
||||||
|
|
||||||
|
const fileCanvasHost =
|
||||||
|
fileConfig.canvasHost && typeof fileConfig.canvasHost === "object" && !Array.isArray(fileConfig.canvasHost)
|
||||||
|
? ({ ...(fileConfig.canvasHost as Record<string, unknown>) } as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
if (typeof testState.canvasHostPort === "number") fileCanvasHost.port = testState.canvasHostPort;
|
||||||
|
const canvasHost = Object.keys(fileCanvasHost).length > 0 ? fileCanvasHost : undefined;
|
||||||
|
|
||||||
|
const hooks =
|
||||||
|
testState.hooksConfig ?? (fileConfig.hooks as HooksConfig | undefined);
|
||||||
|
|
||||||
|
const fileCron =
|
||||||
|
fileConfig.cron && typeof fileConfig.cron === "object" && !Array.isArray(fileConfig.cron)
|
||||||
|
? ({ ...(fileConfig.cron as Record<string, unknown>) } as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
if (typeof testState.cronEnabled === "boolean") fileCron.enabled = testState.cronEnabled;
|
||||||
|
if (typeof testState.cronStorePath === "string") fileCron.store = testState.cronStorePath;
|
||||||
|
const cron = Object.keys(fileCron).length > 0 ? fileCron : undefined;
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
...fileConfig,
|
||||||
|
agents,
|
||||||
|
bindings: testState.bindingsConfig ?? fileBindings,
|
||||||
|
channels,
|
||||||
|
session,
|
||||||
|
gateway,
|
||||||
|
canvasHost,
|
||||||
|
hooks,
|
||||||
|
cron,
|
||||||
|
};
|
||||||
|
return applyPluginAutoEnable({ config, env: process.env }).config;
|
||||||
},
|
},
|
||||||
parseConfigJson5: (raw: string) => {
|
parseConfigJson5: (raw: string) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -68,6 +68,11 @@ export function installTestEnv(): { cleanup: () => void; tempHome: string } {
|
|||||||
{ key: "CLAWDBOT_BRIDGE_PORT", value: process.env.CLAWDBOT_BRIDGE_PORT },
|
{ key: "CLAWDBOT_BRIDGE_PORT", value: process.env.CLAWDBOT_BRIDGE_PORT },
|
||||||
{ key: "CLAWDBOT_CANVAS_HOST_PORT", value: process.env.CLAWDBOT_CANVAS_HOST_PORT },
|
{ key: "CLAWDBOT_CANVAS_HOST_PORT", value: process.env.CLAWDBOT_CANVAS_HOST_PORT },
|
||||||
{ key: "CLAWDBOT_TEST_HOME", value: process.env.CLAWDBOT_TEST_HOME },
|
{ key: "CLAWDBOT_TEST_HOME", value: process.env.CLAWDBOT_TEST_HOME },
|
||||||
|
{ key: "TELEGRAM_BOT_TOKEN", value: process.env.TELEGRAM_BOT_TOKEN },
|
||||||
|
{ key: "DISCORD_BOT_TOKEN", value: process.env.DISCORD_BOT_TOKEN },
|
||||||
|
{ key: "SLACK_BOT_TOKEN", value: process.env.SLACK_BOT_TOKEN },
|
||||||
|
{ key: "SLACK_APP_TOKEN", value: process.env.SLACK_APP_TOKEN },
|
||||||
|
{ key: "SLACK_USER_TOKEN", value: process.env.SLACK_USER_TOKEN },
|
||||||
{ key: "COPILOT_GITHUB_TOKEN", value: process.env.COPILOT_GITHUB_TOKEN },
|
{ key: "COPILOT_GITHUB_TOKEN", value: process.env.COPILOT_GITHUB_TOKEN },
|
||||||
{ key: "GH_TOKEN", value: process.env.GH_TOKEN },
|
{ key: "GH_TOKEN", value: process.env.GH_TOKEN },
|
||||||
{ key: "GITHUB_TOKEN", value: process.env.GITHUB_TOKEN },
|
{ key: "GITHUB_TOKEN", value: process.env.GITHUB_TOKEN },
|
||||||
@@ -91,6 +96,11 @@ export function installTestEnv(): { cleanup: () => void; tempHome: string } {
|
|||||||
delete process.env.CLAWDBOT_BRIDGE_PORT;
|
delete process.env.CLAWDBOT_BRIDGE_PORT;
|
||||||
delete process.env.CLAWDBOT_CANVAS_HOST_PORT;
|
delete process.env.CLAWDBOT_CANVAS_HOST_PORT;
|
||||||
// Avoid leaking real GitHub/Copilot tokens into non-live test runs.
|
// Avoid leaking real GitHub/Copilot tokens into non-live test runs.
|
||||||
|
delete process.env.TELEGRAM_BOT_TOKEN;
|
||||||
|
delete process.env.DISCORD_BOT_TOKEN;
|
||||||
|
delete process.env.SLACK_BOT_TOKEN;
|
||||||
|
delete process.env.SLACK_APP_TOKEN;
|
||||||
|
delete process.env.SLACK_USER_TOKEN;
|
||||||
delete process.env.COPILOT_GITHUB_TOKEN;
|
delete process.env.COPILOT_GITHUB_TOKEN;
|
||||||
delete process.env.GH_TOKEN;
|
delete process.env.GH_TOKEN;
|
||||||
delete process.env.GITHUB_TOKEN;
|
delete process.env.GITHUB_TOKEN;
|
||||||
|
|||||||
Reference in New Issue
Block a user