test: harden gateway mocks and env isolation
This commit is contained in:
@@ -1,9 +1,17 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import fsSync from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { vi } from "vitest";
|
||||
|
||||
import type { ChannelPlugin, ChannelOutboundAdapter } from "../channels/plugins/types.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 = {
|
||||
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(() => ({
|
||||
bridgeStartCalls: [] as BridgeStartOpts[],
|
||||
bridgeInvoke: vi.fn(async () => ({
|
||||
@@ -70,6 +208,21 @@ const hoisted = vi.hoisted(() => ({
|
||||
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 = {
|
||||
value: path.join(os.tmpdir(), `clawdbot-gateway-test-${process.pid}-${crypto.randomUUID()}`),
|
||||
};
|
||||
@@ -91,7 +244,7 @@ export const agentCommand = hoisted.agentCommand;
|
||||
export const testState = {
|
||||
agentConfig: 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,
|
||||
sessionStorePath: undefined as string | undefined,
|
||||
sessionConfig: undefined as Record<string, unknown> | undefined,
|
||||
@@ -100,7 +253,7 @@ export const testState = {
|
||||
cronEnabled: false as boolean | undefined,
|
||||
gatewayBind: undefined as "auto" | "lan" | "tailnet" | "loopback" | 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,
|
||||
legacyIssues: [] as Array<{ path: string; message: string }>,
|
||||
legacyParsed: {} as Record<string, unknown>,
|
||||
@@ -262,61 +415,109 @@ vi.mock("../config/config.js", async () => {
|
||||
changes: testState.migrationChanges,
|
||||
}),
|
||||
loadConfig: () => {
|
||||
const base = {
|
||||
agents: (() => {
|
||||
const defaults = {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(os.tmpdir(), "clawd-gateway-test"),
|
||||
...testState.agentConfig,
|
||||
};
|
||||
if (testState.agentsConfig) {
|
||||
return { ...testState.agentsConfig, defaults };
|
||||
}
|
||||
return { defaults };
|
||||
})(),
|
||||
bindings: testState.bindingsConfig,
|
||||
channels: (() => {
|
||||
const baseChannels =
|
||||
testState.channelsConfig && typeof testState.channelsConfig === "object"
|
||||
? { ...testState.channelsConfig }
|
||||
: {};
|
||||
const existing = baseChannels.whatsapp;
|
||||
const mergedWhatsApp: Record<string, unknown> =
|
||||
existing && typeof existing === "object" && !Array.isArray(existing)
|
||||
? { ...existing }
|
||||
: {};
|
||||
if (testState.allowFrom !== undefined) {
|
||||
mergedWhatsApp.allowFrom = testState.allowFrom;
|
||||
}
|
||||
baseChannels.whatsapp = mergedWhatsApp;
|
||||
return baseChannels;
|
||||
})(),
|
||||
session: {
|
||||
mainKey: "main",
|
||||
store: testState.sessionStorePath,
|
||||
...testState.sessionConfig,
|
||||
},
|
||||
gateway: (() => {
|
||||
const gateway: Record<string, unknown> = {};
|
||||
if (testState.gatewayBind) gateway.bind = testState.gatewayBind;
|
||||
if (testState.gatewayAuth) gateway.auth = testState.gatewayAuth;
|
||||
return Object.keys(gateway).length > 0 ? gateway : undefined;
|
||||
})(),
|
||||
canvasHost: (() => {
|
||||
const canvasHost: Record<string, unknown> = {};
|
||||
if (typeof testState.canvasHostPort === "number")
|
||||
canvasHost.port = testState.canvasHostPort;
|
||||
return Object.keys(canvasHost).length > 0 ? canvasHost : undefined;
|
||||
})(),
|
||||
hooks: testState.hooksConfig,
|
||||
cron: (() => {
|
||||
const cron: Record<string, unknown> = {};
|
||||
if (typeof testState.cronEnabled === "boolean") cron.enabled = testState.cronEnabled;
|
||||
if (typeof testState.cronStorePath === "string") cron.store = testState.cronStorePath;
|
||||
return Object.keys(cron).length > 0 ? cron : undefined;
|
||||
})(),
|
||||
} as ReturnType<typeof actual.loadConfig>;
|
||||
return applyPluginAutoEnable({ config: base }).config;
|
||||
const configPath = resolveConfigPath();
|
||||
let fileConfig: Record<string, unknown> = {};
|
||||
try {
|
||||
if (fsSync.existsSync(configPath)) {
|
||||
const raw = fsSync.readFileSync(configPath, "utf-8");
|
||||
fileConfig = JSON.parse(raw) as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
fileConfig = {};
|
||||
}
|
||||
|
||||
const fileAgents =
|
||||
fileConfig.agents && typeof fileConfig.agents === "object" && !Array.isArray(fileConfig.agents)
|
||||
? (fileConfig.agents as Record<string, unknown>)
|
||||
: {};
|
||||
const fileDefaults =
|
||||
fileAgents.defaults && typeof fileAgents.defaults === "object" && !Array.isArray(fileAgents.defaults)
|
||||
? (fileAgents.defaults as Record<string, unknown>)
|
||||
: {};
|
||||
const defaults = {
|
||||
model: { primary: "anthropic/claude-opus-4-5" },
|
||||
workspace: path.join(os.tmpdir(), "clawd-gateway-test"),
|
||||
...fileDefaults,
|
||||
...testState.agentConfig,
|
||||
};
|
||||
const agents = testState.agentsConfig
|
||||
? { ...fileAgents, ...testState.agentsConfig, defaults }
|
||||
: { ...fileAgents, defaults };
|
||||
|
||||
const fileBindings = Array.isArray(fileConfig.bindings)
|
||||
? (fileConfig.bindings as AgentBinding[])
|
||||
: undefined;
|
||||
|
||||
const fileChannels =
|
||||
fileConfig.channels && typeof fileConfig.channels === "object" && !Array.isArray(fileConfig.channels)
|
||||
? ({ ...(fileConfig.channels as Record<string, unknown>) } as Record<string, unknown>)
|
||||
: {};
|
||||
const overrideChannels =
|
||||
testState.channelsConfig && typeof testState.channelsConfig === "object"
|
||||
? { ...(testState.channelsConfig as Record<string, unknown>) }
|
||||
: {};
|
||||
const mergedChannels = { ...fileChannels, ...overrideChannels };
|
||||
if (testState.allowFrom !== undefined) {
|
||||
const existing =
|
||||
mergedChannels.whatsapp && typeof mergedChannels.whatsapp === "object" && !Array.isArray(mergedChannels.whatsapp)
|
||||
? (mergedChannels.whatsapp as Record<string, unknown>)
|
||||
: {};
|
||||
mergedChannels.whatsapp = {
|
||||
...existing,
|
||||
allowFrom: testState.allowFrom,
|
||||
};
|
||||
}
|
||||
const channels = Object.keys(mergedChannels).length > 0 ? mergedChannels : undefined;
|
||||
|
||||
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) => {
|
||||
try {
|
||||
|
||||
@@ -68,6 +68,11 @@ export function installTestEnv(): { cleanup: () => void; tempHome: string } {
|
||||
{ key: "CLAWDBOT_BRIDGE_PORT", value: process.env.CLAWDBOT_BRIDGE_PORT },
|
||||
{ key: "CLAWDBOT_CANVAS_HOST_PORT", value: process.env.CLAWDBOT_CANVAS_HOST_PORT },
|
||||
{ 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: "GH_TOKEN", value: process.env.GH_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_CANVAS_HOST_PORT;
|
||||
// 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.GH_TOKEN;
|
||||
delete process.env.GITHUB_TOKEN;
|
||||
|
||||
Reference in New Issue
Block a user